diff --git a/.github/workflows/deploy-frontend-azurepreview.yml b/.github/workflows/deploy-frontend-azurepreview.yml
index ca259c1b9f..2ef7adf6be 100644
--- a/.github/workflows/deploy-frontend-azurepreview.yml
+++ b/.github/workflows/deploy-frontend-azurepreview.yml
@@ -13,15 +13,15 @@ env:
KEYVAULT: airqo-kv-staging
jobs:
-
-#########################################
-# FORMAT BRANCH NAME
-#########################################
+ #########################################
+ # FORMAT BRANCH NAME
+ #########################################
branch-name:
runs-on: ubuntu-latest
outputs:
lowercase: ${{ steps.string.outputs.lowercase }}
+ sanitized: ${{ steps.sanitize.outputs.sanitized }}
steps:
- id: string
@@ -29,9 +29,17 @@ jobs:
with:
string: ${{ github.head_ref || github.ref_name }}
-#########################################
-# DETECT CHANGED SERVICES
-#########################################
+ - name: sanitize branch name
+ id: sanitize
+ env:
+ RAW: ${{ steps.string.outputs.lowercase }}
+ run: |
+ CLEAN=$(printf '%s' "${RAW:0:12}" | sed 's/-*$//')
+ echo "sanitized=$CLEAN" >> $GITHUB_OUTPUT
+
+ #########################################
+ # DETECT CHANGED SERVICES
+ #########################################
check:
runs-on: ubuntu-latest
@@ -94,9 +102,9 @@ jobs:
fi
done < files.txt
-#########################################
-# WEBSITE PREVIEW
-#########################################
+ #########################################
+ # WEBSITE PREVIEW
+ #########################################
website:
needs: [check, branch-name]
@@ -132,8 +140,8 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-website-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-website-preview"
ENV_VARS="SECRET=${{ secrets.WEBSITE_SECRET }} API_URL=${{ secrets.WEBSITE_STAGE_API_URL }} OPENCAGE_API_KEY=${{ secrets.WEBSITE_STAGE_OPENCAGE_API_KEY }} API_TOKEN=${{ secrets.WEBSITE_STAGE_API_TOKEN }} SLACK_WEBHOOK_URL=${{ secrets.WEBSITE_STAGE_SLACK_WEBHOOK_URL }} SLACK_CHANNEL=${{ secrets.WEBSITE_STAGE_SLACK_CHANNEL }} NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN }} NEXT_PUBLIC_GA_MEASUREMENT_ID=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_GA_MEASUREMENT_ID }} GOOGLE_SITE_VERIFICATION=${{ secrets.WEBSITE_STAGE_GOOGLE_SITE_VERIFICATION }} NEXT_PUBLIC_SITE_URL=${{ secrets.WEBSITE_STAGE_NEXT_PUBLIC_SITE_URL }}"
az containerapp up \
--name $APP \
@@ -149,8 +157,8 @@ jobs:
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-website-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-website-preview"
URL=$(az containerapp show \
--name $APP \
@@ -174,9 +182,9 @@ jobs:
body: 'New azure website changes available for preview [here](${{ needs.website.outputs.url }})'
})
-#########################################
-# BEACON PREVIEW
-#########################################
+ #########################################
+ # BEACON PREVIEW
+ #########################################
beacon:
name: build-push-deploy-beacon-preview
@@ -214,8 +222,10 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-beacon-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-beacon-preview"
+
+ mapfile -t ENV_VARS < <(jq -r 'to_entries[] | "\(.key)=\(.value|tostring)"' secrets.json)
az containerapp up \
--name $APP \
@@ -227,12 +237,12 @@ jobs:
--registry-server ${{ env.REGISTRY_URL }} \
--registry-username airqoacr \
--registry-password ${{ secrets.ACR_PASSWORD }} \
- --env-vars "$(jq -r 'to_entries | map("\(.key)=\(.value|tostring)") | join(" ")' secrets.json)"
+ --env-vars "${ENV_VARS[@]}"
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-beacon-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-beacon-preview"
URL=$(az containerapp show \
--name $APP \
@@ -256,9 +266,9 @@ jobs:
body: 'New azure beacon changes available for preview [here](${{ needs.beacon.outputs.url }})'
})
-#########################################
-# CALIBRATE APP PREVIEW
-#########################################
+ #########################################
+ # CALIBRATE APP PREVIEW
+ #########################################
calibrate_app:
name: build-push-deploy-calibrate-app-preview
@@ -295,8 +305,10 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-calibrate-app-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-calibrate-app-preview"
+
+ mapfile -t ENV_VARS < <(jq -r 'to_entries[] | "\(.key)=\(.value|tostring)"' secrets.json)
az containerapp up \
--name $APP \
@@ -308,12 +320,12 @@ jobs:
--registry-server ${{ env.REGISTRY_URL }} \
--registry-username airqoacr \
--registry-password ${{ secrets.ACR_PASSWORD }} \
- --env-vars "$(jq -r 'to_entries | map("\(.key)=\(.value|tostring)") | join(" ")' secrets.json)"
+ --env-vars "${ENV_VARS[@]}"
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-calibrate-app-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-calibrate-app-preview"
URL=$(az containerapp show \
--name $APP \
@@ -337,9 +349,9 @@ jobs:
body: 'New azure calibrate_app changes available for preview [here](${{ needs.calibrate_app.outputs.url }})'
})
-#########################################
-# ANALYTICS PLATFORM PREVIEW
-#########################################
+ #########################################
+ # ANALYTICS PLATFORM PREVIEW
+ #########################################
analytics_platform:
name: build-push-deploy-analytics-platform-preview
@@ -364,13 +376,27 @@ jobs:
- name: Pull secrets from Key Vault
run: |
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-analytics-p-preview"
+
+ DEFAULT_DOMAIN=$(az containerapp env show \
+ --name ${{ env.PR_PREVIEW_ENV }} \
+ --resource-group ${{ env.RG }} \
+ --query properties.defaultDomain -o tsv)
+
+ NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN"
+
az keyvault secret show \
--vault-name ${{ env.KEYVAULT }} \
--name sta-env-next-platform \
--query value -o tsv > secrets.json
- # Write .env into the build context for Next.js build time
- jq -r 'to_entries | .[] | "\(.key)=\(.value|tojson)"' secrets.json > src/platform/.env
+ jq -r 'to_entries | .[] | select(.key != "NEXTAUTH_URL" and .key != "NEXTAUTH_URL_INTERNAL" and .key != "NEXTAUTH_COOKIE_DOMAIN" and .key != "AUTH_URL" and .key != "AUTH_TRUST_HOST") | "\(.key)=\(.value|tojson)"' secrets.json > src/platform/.env
+ {
+ echo "NEXTAUTH_URL=$NEXTAUTH_URL"
+ echo "NEXTAUTH_URL_INTERNAL=$NEXTAUTH_URL"
+ echo "AUTH_TRUST_HOST=true"
+ } >> src/platform/.env
- name: Build image
run: |
@@ -379,8 +405,22 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-analytics-p-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-analytics-p-preview"
+
+ DEFAULT_DOMAIN=$(az containerapp env show \
+ --name ${{ env.PR_PREVIEW_ENV }} \
+ --resource-group ${{ env.RG }} \
+ --query properties.defaultDomain -o tsv)
+
+ NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN"
+
+ mapfile -t ENV_VARS < <(jq -r 'to_entries[] | select(.key != "NEXTAUTH_URL" and .key != "NEXTAUTH_URL_INTERNAL" and .key != "NEXTAUTH_COOKIE_DOMAIN" and .key != "AUTH_URL" and .key != "AUTH_TRUST_HOST") | "\(.key)=\(.value|tostring)"' secrets.json)
+ ENV_VARS+=("NEXTAUTH_URL=$NEXTAUTH_URL")
+ ENV_VARS+=("NEXTAUTH_URL_INTERNAL=$NEXTAUTH_URL")
+ ENV_VARS+=("AUTH_TRUST_HOST=true")
+
+ jq -e '((.NEXTAUTH_SECRET // .AUTH_SECRET) | length > 0)' secrets.json >/dev/null
az containerapp up \
--name $APP \
@@ -392,12 +432,28 @@ jobs:
--registry-server ${{ env.REGISTRY_URL }} \
--registry-username airqoacr \
--registry-password ${{ secrets.ACR_PASSWORD }} \
- --env-vars "$(jq -r 'to_entries | map("\(.key)=\(.value|tostring)") | join(" ")' secrets.json)"
+ --env-vars "${ENV_VARS[@]}"
+
+ az containerapp update \
+ --name $APP \
+ --resource-group ${{ env.RG }} \
+ --replace-env-vars "${ENV_VARS[@]}"
+
+ AUTH_STATUS=$(curl -sS -o /tmp/nextauth-providers.json -w "%{http_code}" "$NEXTAUTH_URL/api/auth/providers")
+ if [ "$AUTH_STATUS" != "200" ]; then
+ echo "NextAuth providers endpoint returned HTTP $AUTH_STATUS"
+ cat /tmp/nextauth-providers.json || true
+ az containerapp logs show \
+ --name $APP \
+ --resource-group ${{ env.RG }} \
+ --tail 80 || true
+ exit 1
+ fi
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-analytics-p-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-analytics-p-preview"
URL=$(az containerapp show \
--name $APP \
@@ -421,9 +477,9 @@ jobs:
body: 'New azure analytics_platform changes available for preview [here](${{ needs.analytics_platform.outputs.url }})'
})
-#########################################
-# VERTEX PREVIEW
-#########################################
+ #########################################
+ # VERTEX PREVIEW
+ #########################################
vertex:
name: build-push-deploy-vertex-preview
@@ -448,8 +504,8 @@ jobs:
- name: Pull secrets from Key Vault
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-vertex-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-vertex-preview"
DEFAULT_DOMAIN=$(az containerapp env show \
--name ${{ env.PR_PREVIEW_ENV }} \
@@ -477,8 +533,15 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-vertex-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-vertex-preview"
+
+ DEFAULT_DOMAIN=$(az containerapp env show \
+ --name ${{ env.PR_PREVIEW_ENV }} \
+ --resource-group ${{ env.RG }} \
+ --query properties.defaultDomain -o tsv)
+
+ NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN"
mapfile -t ENV_VARS < <(jq -r 'to_entries[] | select(.key != "NEXTAUTH_URL" and .key != "NEXTAUTH_URL_INTERNAL" and .key != "NEXTAUTH_COOKIE_DOMAIN") | "\(.key)=\(.value|tostring)"' secrets.json)
ENV_VARS+=("NEXTAUTH_URL=$NEXTAUTH_URL")
@@ -510,13 +573,6 @@ jobs:
--query "properties.template.containers[0].env[].name" \
-o tsv | sort
- DEFAULT_DOMAIN=$(az containerapp env show \
- --name ${{ env.PR_PREVIEW_ENV }} \
- --resource-group ${{ env.RG }} \
- --query properties.defaultDomain -o tsv)
-
- NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN"
-
AUTH_STATUS=$(curl -sS -o /tmp/nextauth-providers.json -w "%{http_code}" "$NEXTAUTH_URL/api/auth/providers")
if [ "$AUTH_STATUS" != "200" ]; then
echo "NextAuth providers endpoint returned HTTP $AUTH_STATUS"
@@ -530,8 +586,8 @@ jobs:
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-vertex-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-vertex-preview"
URL=$(az containerapp show \
--name $APP \
@@ -555,9 +611,9 @@ jobs:
body: 'New azure vertex changes available for preview [here](${{ needs.vertex.outputs.url }})'
})
-#########################################
-# DOCS PREVIEW
-#########################################
+ #########################################
+ # DOCS PREVIEW
+ #########################################
docs:
name: build-push-deploy-docs-preview
@@ -594,8 +650,10 @@ jobs:
- name: Deploy Container App
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-docs-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-docs-preview"
+
+ mapfile -t ENV_VARS < <(jq -r 'to_entries[] | "\(.key)=\(.value|tostring)"' secrets.json)
az containerapp up \
--name $APP \
@@ -607,12 +665,12 @@ jobs:
--registry-server ${{ env.REGISTRY_URL }} \
--registry-username airqoacr \
--registry-password ${{ secrets.ACR_PASSWORD }} \
- --env-vars "$(jq -r 'to_entries | map("\(.key)=\(.value|tostring)") | join(" ")' secrets.json)"
+ --env-vars "${ENV_VARS[@]}"
- id: preview
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-docs-preview"
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+ APP="${BRANCH}-docs-preview"
URL=$(az containerapp show \
--name $APP \
@@ -634,4 +692,4 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
body: 'New azure docs changes available for preview [here](${{ needs.docs.outputs.url }})'
- })
+ })
\ No newline at end of file
diff --git a/.github/workflows/remove-deploy-azurepreview.yml b/.github/workflows/remove-deploy-azurepreview.yml
index bfc11e962c..5846fcceec 100644
--- a/.github/workflows/remove-deploy-azurepreview.yml
+++ b/.github/workflows/remove-deploy-azurepreview.yml
@@ -16,226 +16,40 @@ jobs:
name: Format branch name string
runs-on: ubuntu-latest
outputs:
- lowercase: ${{ steps.string.outputs.lowercase }}
+ sanitized: ${{ steps.sanitize.outputs.sanitized }}
steps:
- id: string
- uses: ASzc/change-string-case-action@v2
+ uses: ASzc/change-string-case-action@v5
with:
string: ${{ github.head_ref || github.ref_name }}
- check:
- name: check for available deploy previews
- outputs:
- remove_calibrate_app_preview: ${{ steps.check_files.outputs.remove_calibrate_app_preview }}
- remove_analytics_platform: ${{ steps.check_files.outputs.remove_analytics_platform }}
- remove_vertex: ${{ steps.check_files.outputs.remove_vertex }}
- remove_beacon: ${{ steps.check_files.outputs.remove_beacon }}
- remove_docs: ${{ steps.check_files.outputs.remove_docs }}
- remove_website: ${{ steps.check_files.outputs.remove_website }}
-
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: check modified services
- id: check_files
+ - name: sanitize branch name
+ id: sanitize
+ env:
+ RAW: ${{ steps.string.outputs.lowercase }}
run: |
+ CLEAN=$(printf '%s' "${RAW:0:12}" | sed 's/-*$//')
+ echo "sanitized=$CLEAN" >> $GITHUB_OUTPUT
- git diff --name-only HEAD^ HEAD > files.txt
-
- echo "remove_calibrate_app_preview=false" >>$GITHUB_OUTPUT
- echo "remove_analytics_platform=false" >>$GITHUB_OUTPUT
- echo "remove_vertex=false" >>$GITHUB_OUTPUT
- echo "remove_beacon=false" >>$GITHUB_OUTPUT
- echo "remove_docs=false" >>$GITHUB_OUTPUT
- echo "remove_website=false" >>$GITHUB_OUTPUT
-
- while IFS= read -r file
- do
-
- if [[ $file == src/calibrate/* ]]; then
- echo "remove_calibrate_app_preview=true" >>$GITHUB_OUTPUT
- fi
-
- if [[ $file == src/platform/* ]]; then
- echo "remove_analytics_platform=true" >>$GITHUB_OUTPUT
- fi
-
- if [[ $file == src/vertex/* ]]; then
- echo "remove_vertex=true" >>$GITHUB_OUTPUT
- fi
-
- if [[ $file == src/beacon/* ]]; then
- echo "remove_beacon=true" >>$GITHUB_OUTPUT
- fi
-
- if [[ $file == src/docs-website/* ]]; then
- echo "remove_docs=true" >>$GITHUB_OUTPUT
- fi
-
- if [[ $file == src/website/* ]]; then
- echo "remove_website=true" >>$GITHUB_OUTPUT
- fi
-
- done < files.txt
-
-########################################################
-# REMOVE CALIBRATE APP PREVIEW
-########################################################
-
- remove-calibrate-app-preview:
- name: remove-calibrate-app-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_calibrate_app_preview == 'true'
+ remove-previews:
+ name: remove all previews for branch
+ needs: [branch-name]
runs-on: ubuntu-latest
-
steps:
- name: Azure Login
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- - name: Delete Container App
+ - name: Delete all preview Container Apps for this branch
run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-calibrate-app-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
-
-########################################################
-# REMOVE ANALYTICS PLATFORM PREVIEW
-########################################################
-
- remove-analytics-platform-preview:
- name: remove-analytics-platform-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_analytics_platform == 'true'
- runs-on: ubuntu-latest
-
- steps:
- - name: Azure Login
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: Delete Container App
-
- run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-analytics-p-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
-
-########################################################
-# REMOVE WEBSITE PREVIEW
-########################################################
-
- remove-website-preview:
- name: remove-website-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_website == 'true'
- runs-on: ubuntu-latest
-
- steps:
- - name: Azure Login
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: Delete Container App
- run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-website-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
-
-########################################################
-# REMOVE VERTEX PREVIEW
-########################################################
-
- remove-vertex-preview:
- name: remove-vertex-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_vertex == 'true'
- runs-on: ubuntu-latest
-
- steps:
- - name: Azure Login
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: Delete Container App
- run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-vertex-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
-
-########################################################
-# REMOVE BEACON PREVIEW
-########################################################
-
- remove-beacon-preview:
- name: remove-beacon-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_beacon == 'true'
- runs-on: ubuntu-latest
-
- steps:
- - name: Azure Login
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: Delete Container App
- run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-beacon-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
-
-########################################################
-# REMOVE DOCS PREVIEW
-########################################################
-
- remove-docs-preview:
- name: remove-docs-preview
- needs: [check, branch-name]
- if: needs.check.outputs.remove_docs == 'true'
- runs-on: ubuntu-latest
-
- steps:
- - name: Azure Login
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: Delete Container App
- run: |
- BRANCH="${{ needs.branch-name.outputs.lowercase }}"
- APP="${BRANCH:0:12}-docs-preview"
-
- az containerapp delete \
- --name $APP \
- --resource-group ${{ env.RG}} \
- --yes || echo "Preview not found"
\ No newline at end of file
+ BRANCH="${{ needs.branch-name.outputs.sanitized }}"
+
+ for SERVICE in website beacon calibrate-app analytics-p vertex docs; do
+ APP="${BRANCH}-${SERVICE}-preview"
+ echo "Attempting to delete $APP..."
+ az containerapp delete \
+ --name $APP \
+ --resource-group ${{ env.RG }} \
+ --yes 2>/dev/null || echo "$APP not found, skipping"
+ done
\ No newline at end of file
diff --git a/k8s/beacon/k8s-aks/airqo-beacon/values-prod.yaml b/k8s/beacon/k8s-aks/airqo-beacon/values-prod.yaml
index d5590da14b..35db1e67d1 100644
--- a/k8s/beacon/k8s-aks/airqo-beacon/values-prod.yaml
+++ b/k8s/beacon/k8s-aks/airqo-beacon/values-prod.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-beacon
- tag: prod-ec2d521d-1780041332
+ tag: prod-665d4728-1780315528
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml b/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
index 67588020ef..55d46ab365 100644
--- a/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
+++ b/k8s/beacon/k8s-aks/airqo-beacon/values-stage.yaml
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-stage-beacon
- tag: stage-50d95750-1780041290
+ tag: stage-60a6107f-1780390970
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
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 ca316da30e..e077e5d528 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-f659ef42-1776315130
+ tag: stage-60a6107f-1780390970
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 385c28b994..29c752c89c 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-c6108716-1779973112
+ tag: stage-aa88980d-1780394283
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 bc15ef4786..2c29d716ed 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-b7c498b7-1780301549
+ tag: prod-665d4728-1780315528
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 4716efbdd9..8821059a2b 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-3b81ee2a-1780301491
+ tag: stage-ce0f32db-1780506966
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
diff --git a/src/beacon/README.md b/src/beacon/README.md
index 5afe26376d..6916810613 100644
--- a/src/beacon/README.md
+++ b/src/beacon/README.md
@@ -1,4 +1,4 @@
-# AirQo Device Health Monitoring Frontend.
+# AirQo Device Health Monitoring Frontend
## Node.js Version Requirement
This project requires **Node.js v22.x**. Please ensure you are using Node.js 22 for development and production environments.
diff --git a/src/docs-website/README.md b/src/docs-website/README.md
index b8f8bd2780..df2152c921 100644
--- a/src/docs-website/README.md
+++ b/src/docs-website/README.md
@@ -1,4 +1,4 @@
-# AirQo Digital Products Documentation .
+# AirQo Digital Products Documentation
This repository hosts the centralized documentation hub for all AirQo digital products, including Analytics, Vertex, Beacon, AI Platform, and the API. It is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
diff --git a/src/platform/Dockerfile b/src/platform/Dockerfile
index 0e81611998..b09ba045a7 100644
--- a/src/platform/Dockerfile
+++ b/src/platform/Dockerfile
@@ -42,7 +42,5 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
-ENV PORT=3000
-ENV HOSTNAME=0.0.0.0
-CMD ["node", "server.js"]
\ No newline at end of file
+CMD ["node", "server.js"]
diff --git a/src/platform/README.md b/src/platform/README.md
index cb624566af..d97923b3ec 100644
--- a/src/platform/README.md
+++ b/src/platform/README.md
@@ -1,6 +1,6 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
-## Getting Started
+## Getting Started.
First, run the development server:
@@ -18,7 +18,14 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+## Production Mode
+
+Build and run the production server:
+
+```bash
+npm run build
+npm run start
+```
## Learn More
diff --git a/src/platform/package.json b/src/platform/package.json
index bb6ef54352..37f738db89 100644
--- a/src/platform/package.json
+++ b/src/platform/package.json
@@ -3,12 +3,9 @@
"version": "3.7.0",
"private": true,
"scripts": {
- "clean:next": "node -e \"const fs=require('node:fs'); try { fs.rmSync('.next',{recursive:true,force:true,maxRetries:10,retryDelay:200}); } catch (error) { if (error && typeof error === 'object' && 'code' in error && (error.code === 'EPERM' || error.code === 'EBUSY')) { console.warn('[clean:next] Skipping .next cleanup because the directory is locked:', error.code); } else { throw error; } }\"",
"dev": "next dev",
"build": "next build",
- "prebuild": "yarn clean:next",
- "postbuild": "node -e \"const fs=require('node:fs'); const path=require('node:path'); const projectRoot=process.cwd(); const standaloneRoot=path.join(projectRoot,'.next','standalone'); const publicSource=path.join(projectRoot,'public'); const publicTarget=path.join(standaloneRoot,'public'); const staticSource=path.join(projectRoot,'.next','static'); const staticTarget=path.join(standaloneRoot,'.next','static'); const copyDirectory=(source,target)=>{ if(!fs.existsSync(source)) return false; fs.mkdirSync(path.dirname(target),{recursive:true}); fs.rmSync(target,{recursive:true,force:true}); fs.cpSync(source,target,{recursive:true}); return true; }; if(!fs.existsSync(standaloneRoot)){ throw new Error(`Standalone output not found at ${standaloneRoot}. Run 'yarn build' first.`); } copyDirectory(publicSource, publicTarget); copyDirectory(staticSource, staticTarget);\"",
- "start": "node -e \"const { loadEnvConfig } = require('@next/env'); loadEnvConfig(process.cwd()); process.env.HOSTNAME='localhost'; require('./.next/standalone/server.js')\"",
+ "start": "next start",
"lint": "next lint",
"format": "prettier --write .",
"format:check": "prettier --check ."
diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx
index 9e14a176f8..a4b6f7e5ca 100644
--- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx
+++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx
@@ -143,7 +143,7 @@ export default function LoginPage() {
toast.error(errorTitle, errorMessage);
} else {
toast.success('Welcome back!', 'You have successfully signed in.');
- redirectWithReload(res?.url ?? callbackUrl);
+ redirectWithReload(normalizeCallbackUrl(res?.url) || callbackUrl);
}
} catch (error) {
console.error('Unexpected login error:', error);
diff --git a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx
index 94de8bf2a8..7e0ff838d7 100644
--- a/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx
+++ b/src/platform/src/app/(dashboard)/(organization)/org/[org_slug]/(auth)/login/page.tsx
@@ -138,7 +138,9 @@ export default function OrgLoginPage() {
} else {
toast.success('Welcome back!', 'You have successfully signed in.');
redirectWithReload(
- res?.url ?? callbackUrl ?? `/org/${orgSlug}/dashboard`
+ normalizeCallbackUrl(res?.url) ??
+ callbackUrl ??
+ `/org/${orgSlug}/dashboard`
);
}
} catch (error) {
diff --git a/src/platform/src/app/layout.tsx b/src/platform/src/app/layout.tsx
index 8213c9bc0e..1476b7da8b 100644
--- a/src/platform/src/app/layout.tsx
+++ b/src/platform/src/app/layout.tsx
@@ -2,7 +2,6 @@ import './globals.css';
import '@smastrom/react-rating/style.css';
import type { Metadata } from 'next';
import Script from 'next/script';
-import { Inter } from 'next/font/google';
import NextTopLoader from 'nextjs-toploader';
import { ReduxProvider } from '@/shared/providers/redux-provider';
import { AuthProvider } from '@/shared/providers/auth-provider';
@@ -16,11 +15,6 @@ 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',
-});
-
export const metadata: Metadata = baseMetadata;
export default function RootLayout({
@@ -37,7 +31,7 @@ export default function RootLayout({
dangerouslySetInnerHTML={{ __html: getThemeScript() }}
/>
-
+
= ({
}, [posthog]);
// ── Data ───────────────────────────────────────────────────────────────────
- const { setCountry } = useSitesByCountry({
- country: selectedCountry,
- cohort_id: cohortId,
- });
const mapCohortFilter = isOrganizationFlow
? primaryCohortId || null
: undefined;
@@ -266,7 +262,6 @@ const MapPage: React.FC = ({
const handleCountrySelect = (countryCode: string) => {
setSelectedCountry(countryCode);
- setCountry(countryCode === 'all' ? undefined : countryCode);
};
const handlePollutantChange = (pollutant: 'pm2_5' | 'pm10') => {
diff --git a/src/platform/src/modules/airqo-map/components/sidebar/LocationsList.tsx b/src/platform/src/modules/airqo-map/components/sidebar/LocationsList.tsx
index cdf12a0897..6c18c8b784 100644
--- a/src/platform/src/modules/airqo-map/components/sidebar/LocationsList.tsx
+++ b/src/platform/src/modules/airqo-map/components/sidebar/LocationsList.tsx
@@ -4,6 +4,13 @@ import * as React from 'react';
import { cn } from '@/shared/lib/utils';
import { LocationCard, LocationCardSkeleton } from './LocationCard';
import { AqMarkerPin02 } from '@airqo/icons-react';
+import {
+ getLatestPreferenceForGroup,
+ useUserPreferencesList,
+} from '@/shared/hooks/usePreferences';
+import { useUser } from '@/shared/hooks/useUser';
+import type { Site } from '@/shared/types/api';
+import { getSiteDisplayName } from '@/shared/utils/siteUtils';
import { useSitesByCountry } from '../../hooks';
import {
usePhotonSearch,
@@ -11,11 +18,16 @@ import {
} from '../../hooks/usePhotonSearch';
import { LoadingSpinner } from '../../../../shared/components/ui/loading-spinner';
import {
- normalizeLocations,
filterLocations,
limitLocationsForDisplay,
} from '../../utils/dataNormalization';
+type SiteLike = Partial & {
+ id?: string;
+ site_id?: string;
+ location_name?: string;
+};
+
interface LocationData {
id: string;
title: string;
@@ -38,6 +50,123 @@ interface LocationsListProps {
cohort_id?: string;
}
+const resolveSiteId = (site?: SiteLike | null): string => {
+ const candidateIds = [site?._id, site?.id, site?.site_id];
+
+ return (
+ candidateIds
+ .map(candidateId =>
+ typeof candidateId === 'string' ? candidateId.trim() : ''
+ )
+ .find(Boolean) || ''
+ );
+};
+
+const toCoordinate = (value: unknown): number | undefined => {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string' && value.trim()) {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+
+ return undefined;
+};
+
+const getUsableCoordinates = (latitude?: number, longitude?: number) => {
+ if (
+ latitude === undefined ||
+ longitude === undefined ||
+ (latitude === 0 && longitude === 0)
+ ) {
+ return null;
+ }
+
+ return { latitude, longitude };
+};
+
+const getLocationSummary = (site?: SiteLike | null): string => {
+ const parts = [site?.city, site?.region, site?.country]
+ .map(part => (typeof part === 'string' ? part.trim() : ''))
+ .filter(Boolean);
+
+ return (
+ parts.join(', ') ||
+ (typeof site?.location_name === 'string' && site.location_name.trim()) ||
+ (typeof site?.country === 'string' && site.country.trim()) ||
+ 'Unknown Location'
+ );
+};
+
+const buildLocationData = (site: SiteLike): LocationData | null => {
+ const id = resolveSiteId(site);
+
+ if (!id) {
+ return null;
+ }
+
+ const latitude = toCoordinate(site.approximate_latitude ?? site.latitude);
+ const longitude = toCoordinate(site.approximate_longitude ?? site.longitude);
+ const coordinates = getUsableCoordinates(latitude, longitude);
+
+ return {
+ id,
+ title: getSiteDisplayName({
+ search_name: site.search_name,
+ location_name: site.location_name,
+ name: site.name,
+ formatted_name: site.formatted_name,
+ }),
+ location: getLocationSummary(site),
+ latitude: coordinates?.latitude,
+ longitude: coordinates?.longitude,
+ };
+};
+
+const normalizeFavoriteSites = (
+ selectedSiteIds: string[],
+ selectedSites: SiteLike[]
+): SiteLike[] => {
+ const favoriteSitesById = new Map();
+
+ selectedSites.forEach(selectedSite => {
+ const siteId = resolveSiteId(selectedSite);
+
+ if (!siteId) {
+ return;
+ }
+
+ favoriteSitesById.set(siteId, {
+ ...selectedSite,
+ _id: siteId,
+ search_name:
+ selectedSite.search_name ||
+ selectedSite.formatted_name ||
+ selectedSite.generated_name ||
+ selectedSite.name ||
+ siteId,
+ });
+ });
+
+ return selectedSiteIds.map(siteId => {
+ const favoriteSite = favoriteSitesById.get(siteId);
+
+ if (favoriteSite) {
+ return favoriteSite;
+ }
+
+ return {
+ _id: siteId,
+ search_name: siteId,
+ name: siteId,
+ };
+ });
+};
+
export const LocationsList: React.FC = ({
selectedCountry,
onLocationSelect,
@@ -46,18 +175,95 @@ export const LocationsList: React.FC = ({
selectedLocationId,
cohort_id,
}) => {
+ const { user, activeGroup, isLoading: userLoading } = useUser();
+ const isSearching = searchQuery.trim().length > 0;
+ const shouldResolveInitialFavorites =
+ !isSearching && selectedCountry === undefined;
+ const hasPreferenceContext = Boolean(user?.id && activeGroup?.id);
+ const shouldFetchPreferences =
+ shouldResolveInitialFavorites && hasPreferenceContext;
+
+ const { data: preferencesData, isLoading: preferencesLoading } =
+ useUserPreferencesList(
+ shouldFetchPreferences ? user?.id || '' : '',
+ shouldFetchPreferences ? activeGroup?.id || '' : ''
+ );
+
+ const currentPreference = React.useMemo(() => {
+ return getLatestPreferenceForGroup(
+ preferencesData?.preferences,
+ activeGroup?.id
+ );
+ }, [activeGroup?.id, preferencesData?.preferences]);
+
+ const favoriteSiteIds = React.useMemo(() => {
+ if (!currentPreference) {
+ return [];
+ }
+
+ const canonicalSiteIds = Array.isArray(currentPreference.site_ids)
+ ? currentPreference.site_ids
+ : [];
+ const fallbackSiteIds = Array.isArray(currentPreference.selected_sites)
+ ? currentPreference.selected_sites.map(resolveSiteId)
+ : [];
+
+ return Array.from(
+ new Set(
+ [...canonicalSiteIds, ...fallbackSiteIds]
+ .map(siteId => (typeof siteId === 'string' ? siteId.trim() : ''))
+ .filter(Boolean)
+ )
+ );
+ }, [currentPreference]);
+
+ const favoriteSites = React.useMemo(() => {
+ if (!currentPreference) {
+ return [];
+ }
+
+ return normalizeFavoriteSites(
+ favoriteSiteIds,
+ Array.isArray(currentPreference.selected_sites)
+ ? currentPreference.selected_sites
+ : []
+ );
+ }, [currentPreference, favoriteSiteIds]);
+
+ const favoriteLocations = React.useMemo(() => {
+ return favoriteSites
+ .map(buildLocationData)
+ .filter((location): location is LocationData => location !== null);
+ }, [favoriteSites]);
+
+ const isWaitingForFavoriteDecision =
+ shouldResolveInitialFavorites &&
+ (userLoading || (hasPreferenceContext && preferencesLoading));
+ const shouldUseFavoriteLocations =
+ shouldResolveInitialFavorites &&
+ !isWaitingForFavoriteDecision &&
+ favoriteLocations.length > 0;
+ const shouldFetchSites =
+ isSearching ||
+ selectedCountry !== undefined ||
+ (!shouldResolveInitialFavorites && !isSearching) ||
+ (!isWaitingForFavoriteDecision &&
+ (!hasPreferenceContext || favoriteLocations.length === 0));
+
const { sites, isLoading, isLoadingMore, loadMore, hasNextPage } =
useSitesByCountry({
country: selectedCountry === 'all' ? undefined : selectedCountry,
cohort_id,
+ enabled: shouldFetchSites,
});
const { results: photonResults, isLoading: isPhotonLoading } =
usePhotonSearch(searchQuery, searchQuery.length > 0);
- // Transform sites data to Location format using utility function
const locations = React.useMemo(() => {
- return normalizeLocations(sites);
+ return sites
+ .map(site => buildLocationData(site as SiteLike))
+ .filter((location): location is LocationData => location !== null);
}, [sites]);
// Filter locations based on search query using utility function (only for internal sites)
@@ -65,9 +271,6 @@ export const LocationsList: React.FC = ({
return filterLocations(locations, searchQuery);
}, [locations, searchQuery]);
- // Determine if currently searching
- const isSearching = searchQuery.trim().length > 0;
-
// Use Photon results if searching and has results, otherwise use filtered internal locations
const displayLocations = React.useMemo(() => {
if (isSearching && photonResults.length > 0) {
@@ -84,21 +287,42 @@ export const LocationsList: React.FC = ({
photonData: result,
}));
}
+
+ if (shouldUseFavoriteLocations) {
+ return favoriteLocations;
+ }
+
return filteredLocations;
- }, [isSearching, photonResults, filteredLocations]);
+ }, [
+ favoriteLocations,
+ filteredLocations,
+ isSearching,
+ photonResults,
+ shouldUseFavoriteLocations,
+ ]);
+
+ const hasActivePagination = shouldUseFavoriteLocations ? false : hasNextPage;
// Limit locations for display using utility function
const { displayed: displayedLocations } = React.useMemo(() => {
return limitLocationsForDisplay(
displayLocations,
- isSearching || hasNextPage,
+ isSearching || hasActivePagination,
6
);
- }, [displayLocations, isSearching, hasNextPage]);
+ }, [displayLocations, hasActivePagination, isSearching]);
+
+ const isSearchFallbackLoading =
+ isSearching && photonResults.length === 0 && shouldFetchSites && isLoading;
+ const showLoadingSkeletons =
+ (!isSearching &&
+ (isWaitingForFavoriteDecision || (shouldFetchSites && isLoading))) ||
+ (isSearching && (isPhotonLoading || isSearchFallbackLoading));
const hasNoResults =
- isSearching && displayLocations.length === 0 && !isPhotonLoading;
- const hasNoSites = !isSearching && !isLoading && sites.length === 0;
+ isSearching && displayLocations.length === 0 && !showLoadingSkeletons;
+ const hasNoSites =
+ !isSearching && !showLoadingSkeletons && displayLocations.length === 0;
const handleLocationClick = (location: LocationData) => {
if (location.isPhotonResult && location.photonData) {
@@ -109,14 +333,17 @@ export const LocationsList: React.FC = ({
name: location.title,
});
} else {
- // Internal site
- const siteData = sites.find(site => site._id === location.id);
+ const coordinates = getUsableCoordinates(
+ location.latitude,
+ location.longitude
+ );
+
onLocationSelect?.(
location.id,
- siteData
+ coordinates
? {
- latitude: siteData.approximate_latitude as number,
- longitude: siteData.approximate_longitude as number,
+ latitude: coordinates.latitude,
+ longitude: coordinates.longitude,
name: location.title,
}
: undefined
@@ -157,7 +384,7 @@ export const LocationsList: React.FC = ({
) : (
<>
- {(isLoading && !isSearching) || (isPhotonLoading && isSearching)
+ {showLoadingSkeletons
? // Show loading skeletons
Array.from({ length: 6 }, (_, index) => (
@@ -175,19 +402,22 @@ export const LocationsList: React.FC = ({
{/* Load more button or loading spinner */}
- {hasNextPage && !isSearching && !isLoading && !isLoadingMore && (
-
-
- Show more
-
-
- )}
+ {hasActivePagination &&
+ !isSearching &&
+ !isLoading &&
+ !isLoadingMore && (
+
+
+ Show more
+
+
+ )}
{/* Loading spinner for load more */}
- {isLoadingMore && (
+ {!shouldUseFavoriteLocations && isLoadingMore && (
diff --git a/src/platform/src/modules/airqo-map/hooks/useMapReadings.ts b/src/platform/src/modules/airqo-map/hooks/useMapReadings.ts
index 86e8ff3181..177d3ef975 100644
--- a/src/platform/src/modules/airqo-map/hooks/useMapReadings.ts
+++ b/src/platform/src/modules/airqo-map/hooks/useMapReadings.ts
@@ -1,13 +1,59 @@
'use client';
-import { useCallback } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { deviceService } from '../../../shared/services/deviceService';
-import { useUser } from '../../../shared/hooks/useUser';
import type {
MapReadingsResponse,
MapReading,
} from '../../../shared/types/api';
+const MAP_READINGS_AUTO_RETRY_DELAY_MS = 750;
+
+const isAbortLikeError = (error: unknown): boolean => {
+ const candidate = error as {
+ name?: string;
+ code?: string;
+ message?: string;
+ } | null;
+
+ if (!candidate) {
+ return false;
+ }
+
+ return (
+ candidate.name === 'AbortError' ||
+ candidate.name === 'CanceledError' ||
+ candidate.code === 'ERR_CANCELED' ||
+ candidate.message === 'canceled'
+ );
+};
+
+const getErrorStatus = (error: unknown): number | null => {
+ if (!error || typeof error !== 'object') {
+ return null;
+ }
+
+ const candidate = error as {
+ status?: number;
+ response?: { status?: number };
+ };
+
+ if (typeof candidate.response?.status === 'number') {
+ return candidate.response.status;
+ }
+
+ return typeof candidate.status === 'number' ? candidate.status : null;
+};
+
+const shouldAutoRetryMapReadings = (error: unknown): boolean => {
+ if (isAbortLikeError(error)) {
+ return true;
+ }
+
+ const status = getErrorStatus(error);
+ return status !== null && status >= 500 && status < 600;
+};
+
export interface UseMapReadingsResult {
readings: MapReading[];
isLoading: boolean;
@@ -22,14 +68,15 @@ export interface UseMapReadingsResult {
export function useMapReadings(
cohort_id?: string | null
): UseMapReadingsResult {
- const { user } = useUser();
const normalizedCohortId =
cohort_id === null ? 'disabled' : (cohort_id ?? 'all');
const enabled = cohort_id !== null;
+ const autoRetriedKeyRef = useRef
(null);
const {
data: readings = [],
isLoading,
+ isFetching,
error,
refetch: refetchQuery,
} = useQuery({
@@ -38,8 +85,7 @@ export function useMapReadings(
const response: MapReadingsResponse =
await deviceService.getMapReadingsWithToken(
cohort_id || undefined,
- signal,
- user?.id
+ signal
);
return response.measurements;
},
@@ -52,6 +98,30 @@ export function useMapReadings(
gcTime: 1000 * 60 * 60 * 12,
});
+ useEffect(() => {
+ autoRetriedKeyRef.current = null;
+ }, [normalizedCohortId]);
+
+ useEffect(() => {
+ if (!enabled || !error || !shouldAutoRetryMapReadings(error)) {
+ return;
+ }
+
+ if (autoRetriedKeyRef.current === normalizedCohortId) {
+ return;
+ }
+
+ autoRetriedKeyRef.current = normalizedCohortId;
+
+ const retryTimer = window.setTimeout(() => {
+ void refetchQuery();
+ }, MAP_READINGS_AUTO_RETRY_DELAY_MS);
+
+ return () => {
+ window.clearTimeout(retryTimer);
+ };
+ }, [enabled, error, normalizedCohortId, refetchQuery]);
+
const refetch = useCallback(async () => {
await refetchQuery();
}, [refetchQuery]);
@@ -69,7 +139,7 @@ export function useMapReadings(
return {
readings,
- isLoading,
+ isLoading: isLoading || (readings.length === 0 && isFetching),
error: error?.message ?? null,
refetch,
};
diff --git a/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx b/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx
index d3c7aa6028..d7cc118742 100644
--- a/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx
+++ b/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx
@@ -289,7 +289,7 @@ export const AnalyticsDashboard: React.FC = ({
_id: siteData._id,
name: displayName,
search_name: displayName,
- country: siteData.location,
+ country: siteData.country || siteData.location,
};
dispatch(openMoreInsights({ sites: [selectedSite] }));
diff --git a/src/platform/src/modules/analytics/hooks/index.ts b/src/platform/src/modules/analytics/hooks/index.ts
index f98fa699bb..9ccb8e134f 100644
--- a/src/platform/src/modules/analytics/hooks/index.ts
+++ b/src/platform/src/modules/analytics/hooks/index.ts
@@ -9,7 +9,11 @@ import {
} from '@/shared/hooks/usePreferences';
import { useUser } from '@/shared/hooks/useUser';
import { normalizeAirQualityData } from '@/shared/components/charts/utils';
-import { generateTrend, getAirQualityLevel } from '../utils';
+import {
+ generateTrend,
+ getAirQualityLevel,
+ normalizeRecentReadingsToSiteData,
+} from '../utils';
import { getSiteDisplayName } from '@/shared/utils/siteUtils';
import { useAnalytics } from './useAnalytics';
import { analyticsService } from '@/shared/services/analyticsService';
@@ -30,19 +34,90 @@ interface AnalyticsSelections {
enabled?: boolean;
}
+type PreferenceSite = Partial & {
+ id?: string;
+ site_id?: string;
+};
+
const EMPTY_SELECTED_SITE_IDS: string[] = [];
const ANALYTICS_QUERY_STALE_TIME_MS = 1000 * 60 * 5;
const ANALYTICS_QUERY_GC_TIME_MS = 1000 * 60 * 60 * 12;
const SITE_CARD_LOOKBACK_WINDOW_MS = 1000 * 60 * 60 * 24;
+const resolvePreferenceSiteId = (site?: PreferenceSite | null): string => {
+ const candidateIds = [site?._id, site?.id, site?.site_id];
+
+ return (
+ candidateIds
+ .map(candidateId =>
+ typeof candidateId === 'string' ? candidateId.trim() : ''
+ )
+ .find(Boolean) || ''
+ );
+};
+
+const normalizePreferenceSelectedSites = (
+ selectedSiteIds: string[],
+ selectedSites: PreferenceSite[]
+): Site[] => {
+ const normalizedSitesById = new Map();
+
+ selectedSites.forEach(selectedSite => {
+ const siteId = resolvePreferenceSiteId(selectedSite);
+
+ if (!siteId) {
+ return;
+ }
+
+ normalizedSitesById.set(siteId, {
+ search_name:
+ selectedSite.search_name ||
+ selectedSite.formatted_name ||
+ selectedSite.generated_name ||
+ selectedSite.name ||
+ siteId,
+ ...selectedSite,
+ _id: siteId,
+ });
+ });
+
+ return selectedSiteIds.map(siteId => {
+ const existingSite = normalizedSitesById.get(siteId);
+
+ if (existingSite) {
+ return existingSite;
+ }
+
+ return {
+ _id: siteId,
+ search_name: siteId,
+ name: siteId,
+ };
+ });
+};
+
+const buildSiteCardLocation = (selectedSite: Site) => {
+ return (
+ [selectedSite.city, selectedSite.region, selectedSite.country]
+ .filter(Boolean)
+ .join(', ') ||
+ selectedSite.country ||
+ 'Unknown Country'
+ );
+};
+
const buildNoValueSiteCard = (
selectedSite: Site,
pollutant: string
): SiteData => ({
_id: selectedSite._id,
name: getSiteDisplayName(selectedSite),
- location: selectedSite.country || 'Unknown Country',
+ search_name: getSiteDisplayName(selectedSite),
+ location: buildSiteCardLocation(selectedSite),
+ country: selectedSite.country,
+ city: selectedSite.city,
+ region: selectedSite.region,
value: 0,
status: 'no-value',
pollutant,
@@ -51,14 +126,43 @@ const buildNoValueSiteCard = (
percentageDifference: 0,
});
-const buildSiteCardLocation = (selectedSite: Site) => {
- return (
- [selectedSite.city, selectedSite.region, selectedSite.country]
- .filter(Boolean)
- .join(', ') ||
- selectedSite.country ||
- 'Unknown Country'
+const buildSiteCardsFromRecentReadings = (
+ siteCards: SiteData[],
+ selectedSites: Site[],
+ pollutant: 'pm2_5' | 'pm10'
+): SiteData[] => {
+ if (!Array.isArray(siteCards) || siteCards.length === 0) {
+ return selectedSites.map(selectedSite =>
+ buildNoValueSiteCard(selectedSite, pollutant)
+ );
+ }
+
+ const siteCardsById = new Map(
+ siteCards.map(siteCard => [siteCard._id, siteCard] as const)
);
+
+ return selectedSites.map(selectedSite => {
+ const siteCard = siteCardsById.get(selectedSite._id);
+
+ if (!siteCard) {
+ return buildNoValueSiteCard(selectedSite, pollutant);
+ }
+
+ const fallbackDisplayName = getSiteDisplayName(selectedSite);
+ const apiDisplayName = getSiteDisplayName(siteCard);
+
+ return {
+ ...siteCard,
+ _id: selectedSite._id,
+ 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,
+ region: siteCard.region ?? selectedSite.region,
+ pollutant,
+ };
+ });
};
const createLatestSiteCardDateRange = () => {
@@ -74,7 +178,7 @@ const createLatestSiteCardDateRange = () => {
const buildSiteCardsFromChartPoints = (
chartPoints: ChartDataPoint[],
selectedSites: Site[],
- pollutant: string
+ pollutant: 'pm2_5' | 'pm10'
): SiteData[] => {
if (!Array.isArray(chartPoints) || chartPoints.length === 0) {
return selectedSites.map(selectedSite =>
@@ -132,6 +236,15 @@ const buildSiteCardsFromChartPoints = (
});
};
+const isRecentReadingsCompatibilityFallbackError = (
+ error: unknown
+): boolean => {
+ const status = (error as { response?: { status?: number } } | null)?.response
+ ?.status;
+
+ return status === 404 || status === 405;
+};
+
interface AnalyticsPreferencesOptions {
groupId?: string;
userId?: string;
@@ -175,10 +288,26 @@ export const useAnalyticsPreferences = (
return [];
}
- if (!currentPreference?.selected_sites) {
+ const canonicalSiteIds = Array.isArray(currentPreference.site_ids)
+ ? currentPreference.site_ids
+ : [];
+ const fallbackSiteIds = Array.isArray(currentPreference.selected_sites)
+ ? currentPreference.selected_sites.map(resolvePreferenceSiteId)
+ : [];
+
+ const normalizedSiteIds = Array.from(
+ new Set(
+ [...canonicalSiteIds, ...fallbackSiteIds]
+ .map(siteId => (typeof siteId === 'string' ? siteId.trim() : ''))
+ .filter(Boolean)
+ )
+ );
+
+ if (normalizedSiteIds.length === 0) {
return [];
}
- return currentPreference.selected_sites.map((site: Site) => site._id);
+
+ return normalizedSiteIds;
}, [currentPreference, preferencesLoading]);
// Get full selected sites data
@@ -188,8 +317,11 @@ export const useAnalyticsPreferences = (
return [];
}
- return currentPreference?.selected_sites || [];
- }, [currentPreference, preferencesLoading]);
+ return normalizePreferenceSelectedSites(
+ selectedSiteIds,
+ currentPreference?.selected_sites || []
+ );
+ }, [currentPreference, preferencesLoading, selectedSiteIds]);
// Combined loading state - only show loading if we should fetch and are actually loading
const isWaitingForGroup = isEnabled && !resolvedGroupId;
@@ -359,9 +491,25 @@ export const useAnalyticsSiteCards = ({
() => selectedSiteIds.join(','),
[selectedSiteIds]
);
+ const selectedSitesKey = useMemo(
+ () =>
+ selectedSites
+ .map(site =>
+ [
+ site._id,
+ getSiteDisplayName(site),
+ site.city ?? '',
+ site.region ?? '',
+ site.country ?? '',
+ ].join(':')
+ )
+ .join('|'),
+ [selectedSites]
+ );
const activeGroupKey = activeGroup?.id ?? 'no-active-group';
- const shouldFetch = enabled && selectedSiteIds.length > 0;
+ const shouldFetch =
+ enabled && selectedSiteIds.length > 0 && selectedSites.length > 0;
const queryKey = useMemo(
() => [
'analytics',
@@ -369,9 +517,16 @@ export const useAnalyticsSiteCards = ({
user?.id ?? 'anonymous',
activeGroupKey,
selectedSiteIdsKey,
+ selectedSitesKey,
filters.pollutant,
],
- [activeGroupKey, filters.pollutant, selectedSiteIdsKey, user?.id]
+ [
+ activeGroupKey,
+ filters.pollutant,
+ selectedSiteIdsKey,
+ selectedSitesKey,
+ user?.id,
+ ]
);
const currentRequestKey = useMemo(() => JSON.stringify(queryKey), [queryKey]);
const lastSettledRequestKeyRef = useRef(currentRequestKey);
@@ -379,25 +534,53 @@ export const useAnalyticsSiteCards = ({
const query = useQuery({
queryKey,
queryFn: async ({ signal }) => {
- const latestDateRange = createLatestSiteCardDateRange();
- const response = await analyticsService.getChartData(
- {
- sites: selectedSiteIds,
- startDate: latestDateRange.startDate,
- endDate: latestDateRange.endDate,
- chartType: 'line',
- frequency: 'hourly',
- pollutant: filters.pollutant.toLowerCase().replace('.', '_'),
- organisation_name: '',
- },
- signal
- );
+ const activePollutant: 'pm2_5' | 'pm10' =
+ filters.pollutant === 'pm10' ? 'pm10' : 'pm2_5';
- return buildSiteCardsFromChartPoints(
- response?.data ?? [],
- selectedSites,
- filters.pollutant
- );
+ try {
+ const response = await analyticsService.getRecentReadings(
+ {
+ site_id: selectedSiteIds.join(','),
+ },
+ signal
+ );
+
+ return buildSiteCardsFromRecentReadings(
+ normalizeRecentReadingsToSiteData(
+ response?.measurements ?? [],
+ activePollutant
+ ),
+ selectedSites,
+ activePollutant
+ );
+ } catch (error) {
+ if (
+ signal.aborted ||
+ !isRecentReadingsCompatibilityFallbackError(error)
+ ) {
+ throw error;
+ }
+
+ const latestDateRange = createLatestSiteCardDateRange();
+ const fallbackResponse = await analyticsService.getChartData(
+ {
+ sites: selectedSiteIds,
+ startDate: latestDateRange.startDate,
+ endDate: latestDateRange.endDate,
+ chartType: 'line',
+ frequency: 'hourly',
+ pollutant: activePollutant,
+ organisation_name: '',
+ },
+ signal
+ );
+
+ return buildSiteCardsFromChartPoints(
+ fallbackResponse?.data ?? [],
+ selectedSites,
+ activePollutant
+ );
+ }
},
enabled: shouldFetch,
networkMode: 'online',
diff --git a/src/platform/src/modules/analytics/utils/index.ts b/src/platform/src/modules/analytics/utils/index.ts
index c34445a8f1..0bf3e71962 100644
--- a/src/platform/src/modules/analytics/utils/index.ts
+++ b/src/platform/src/modules/analytics/utils/index.ts
@@ -242,6 +242,18 @@ export const calculateAverageAirQuality = (
};
};
+const getRecentReadingSiteName = (
+ siteDetails: RecentReading['siteDetails'] | undefined
+): string => {
+ return (
+ siteDetails?.search_name?.trim() ||
+ siteDetails?.name?.trim() ||
+ siteDetails?.location_name?.trim() ||
+ siteDetails?.formatted_name?.trim() ||
+ 'Unknown Location'
+ );
+};
+
/**
* Normalize recent readings data to SiteData format for analytics cards
* @param measurements - Array of recent readings from API
@@ -258,15 +270,18 @@ export const normalizeRecentReadingsToSiteData = (
return measurements.map(measurement => {
const { siteDetails, averages, pm2_5, pm10 } = measurement;
- const displayName = getSiteDisplayName(siteDetails);
+ const displayName = getRecentReadingSiteName(siteDetails);
+ const country = siteDetails?.country?.trim() || undefined;
// Get value based on active pollutant
const pollutantValue =
activePollutant === 'pm2_5' ? pm2_5?.value : pm10?.value;
const value =
- pollutantValue !== null && pollutantValue !== undefined
+ typeof pollutantValue === 'number' && Number.isFinite(pollutantValue)
? pollutantValue
: 0;
+ const hasPollutantValue =
+ typeof pollutantValue === 'number' && Number.isFinite(pollutantValue);
// Determine trend based on percentage difference
let trend: 'up' | 'down' | 'stable' = 'stable';
@@ -277,14 +292,21 @@ export const normalizeRecentReadingsToSiteData = (
}
// Get air quality level
- const status = getAirQualityLevel(value, activePollutant);
+ const status = hasPollutantValue
+ ? getAirQualityLevel(value, activePollutant)
+ : 'no-value';
return {
_id: siteDetails?._id || measurement.site_id,
name: displayName,
- location: siteDetails?.country || 'Unknown Country',
+ search_name: displayName,
+ location: country || 'Unknown Country',
+ country,
+ city: siteDetails?.city,
+ region: siteDetails?.region,
value,
status,
+ aqi_category: measurement.aqi_category,
pollutant: activePollutant,
unit: 'μg/m³',
trend,
diff --git a/src/platform/src/shared/components/charts/components/charts/DynamicChart.tsx b/src/platform/src/shared/components/charts/components/charts/DynamicChart.tsx
index a60df1151e..a51d46d646 100644
--- a/src/platform/src/shared/components/charts/components/charts/DynamicChart.tsx
+++ b/src/platform/src/shared/components/charts/components/charts/DynamicChart.tsx
@@ -191,6 +191,14 @@ export const DynamicChart: React.FC = ({
return { left: 0, right: 0 };
}, [chartType]);
+ const xAxisInterval = useMemo(() => {
+ if (chartData.length <= 6) {
+ return 0;
+ }
+
+ return Math.max(Math.ceil(chartData.length / 6) - 1, 0);
+ }, [chartData.length]);
+
// Common props for all charts
const commonProps = {
data: chartData as unknown as NormalizedChartData[],
@@ -214,7 +222,7 @@ export const DynamicChart: React.FC = ({
= ({
const renderYAxis = () => (
= ({
return (
= ({
className,
}) => {
const pathname = usePathname();
- const [isVisible, setIsVisible] = React.useState(true);
- const [lastScrollY, setLastScrollY] = React.useState(0);
const { activeGroup } = useUserActions();
// Determine current flow
@@ -55,92 +53,68 @@ export const BottomNavigation: React.FC = ({
return items;
}, [flow, activeGroup?.organizationSlug]);
- React.useEffect(() => {
- const handleScroll = () => {
- const currentScrollY = window.scrollY;
-
- // Show navigation when scrolling up or at the top
- if (currentScrollY < lastScrollY || currentScrollY < 100) {
- setIsVisible(true);
- } else if (currentScrollY > lastScrollY && currentScrollY > 100) {
- // Hide navigation when scrolling down (after 100px from top)
- setIsVisible(false);
- }
-
- setLastScrollY(currentScrollY);
- };
-
- window.addEventListener('scroll', handleScroll, { passive: true });
- return () => window.removeEventListener('scroll', handleScroll);
- }, [lastScrollY]);
-
return (
-
- {isVisible && (
-
-
- {navItems.map(item => {
- const Icon = item.icon;
- const isActive =
- pathname === item.href || pathname.startsWith(item.href + '/');
+
+
+ {navItems.map(item => {
+ const Icon = item.icon;
+ const isActive =
+ pathname === item.href || pathname.startsWith(item.href + '/');
- return (
-
+ return (
+
+
+
+ {isActive && (
-
- {isActive && (
-
- )}
-
-
- {item.label}
-
-
- );
- })}
-
-
- )}
-
+ layoutId="bottomNavIndicator"
+ className="absolute -bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-primary"
+ initial={false}
+ transition={{
+ type: 'spring',
+ stiffness: 500,
+ damping: 30,
+ }}
+ />
+ )}
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
);
};
diff --git a/src/platform/src/shared/layouts/MainLayout.tsx b/src/platform/src/shared/layouts/MainLayout.tsx
index fa9230a220..0e043d9dc1 100644
--- a/src/platform/src/shared/layouts/MainLayout.tsx
+++ b/src/platform/src/shared/layouts/MainLayout.tsx
@@ -79,7 +79,7 @@ export const MainLayout: React.FC = ({
diff --git a/src/platform/src/shared/providers/UserDataFetcher.tsx b/src/platform/src/shared/providers/UserDataFetcher.tsx
index 21eb760d4b..9e4242aee0 100644
--- a/src/platform/src/shared/providers/UserDataFetcher.tsx
+++ b/src/platform/src/shared/providers/UserDataFetcher.tsx
@@ -21,6 +21,7 @@ import {
import { useLogout } from '@/shared/hooks/useLogout';
import type { User } from '@/shared/types/api';
import { normalizeUser, normalizeGroups } from '@/shared/utils/userUtils';
+import { LoadingOverlay } from '@/shared/components/ui/loading-overlay';
/**
* Component that automatically fetches and stores user data when authenticated
@@ -34,7 +35,7 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
const user = useSelector(selectUser);
const logout = useLogout();
const isLoggingOut = useSelector(selectLoggingOut);
- useActiveGroupCohorts();
+ const hasLoggedOutForNoGroupRef = useRef(false);
// Memoize userId to prevent unnecessary re-calculations
const userId = useMemo(() => {
@@ -42,9 +43,34 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
? (session.user as { _id: string })._id
: null;
}, [session?.user]);
+ const persistedUserId = user?.id ?? null;
+ const hasStalePersistedUser =
+ status === 'authenticated' &&
+ !!userId &&
+ !!persistedUserId &&
+ persistedUserId !== userId;
// Fetch user details only when userId is available and stable
const { data, error, isLoading } = useUserDetails(userId);
+ const fetchedUser = data?.users?.[0] as User | undefined;
+ const fetchedGroups = useMemo(
+ () => normalizeGroups(fetchedUser?.groups),
+ [fetchedUser?.groups]
+ );
+ const activeGroupMissingFromFreshGroups =
+ !!fetchedUser &&
+ !!activeGroup?.id &&
+ !fetchedGroups.some(group => group.id === activeGroup.id);
+ const canUseUserScopedState =
+ status === 'authenticated' &&
+ !!userId &&
+ !!persistedUserId &&
+ persistedUserId === userId &&
+ !isLoading &&
+ !error &&
+ !activeGroupMissingFromFreshGroups;
+
+ useActiveGroupCohorts(canUseUserScopedState && !!activeGroup?.id);
// Clear user data when userId changes (different user logged in)
const prevUserIdRef = useRef(userId);
@@ -52,18 +78,17 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
const prevUserId = prevUserIdRef.current;
prevUserIdRef.current = userId;
- if (userId !== prevUserId && userId) {
+ if (userId && (userId !== prevUserId || hasStalePersistedUser)) {
dispatch(clearUser());
hasLoggedOutForNoGroupRef.current = false; // Reset for new user
}
- }, [userId, dispatch]);
+ }, [userId, hasStalePersistedUser, dispatch]);
// Use refs to track previous values and prevent unnecessary dispatches
const prevStatusRef = useRef(status);
const prevDataRef = useRef(data);
const prevErrorRef = useRef(error);
const prevIsLoadingRef = useRef(isLoading);
- const hasLoggedOutForNoGroupRef = useRef(false);
// Handle authentication status changes
useEffect(() => {
@@ -110,10 +135,9 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
prevDataRef.current = data;
// Only process data when it actually changes and is valid
- if (data !== prevData && data?.users && data.users.length > 0) {
- const fetchedUser = data.users[0] as User;
+ if (data !== prevData && fetchedUser) {
const normalizedUser = normalizeUser(fetchedUser);
- const normalizedGroups = normalizeGroups(fetchedUser.groups);
+ const normalizedGroups = fetchedGroups;
if (normalizedUser) {
dispatch(setUser(normalizedUser));
@@ -125,7 +149,7 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
dispatch(setError('Invalid user data received from API'));
}
}
- }, [data, dispatch]);
+ }, [data, dispatch, fetchedGroups, fetchedUser]);
// Check if user has no active group after data is loaded and logout if necessary
useEffect(() => {
@@ -144,5 +168,13 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) {
// Note: Preferences are managed entirely by SWR in individual components to prevent loops
// The Redux preferences store is used for cross-component state sharing, not data fetching
+ if (
+ hasStalePersistedUser ||
+ (status === 'authenticated' && !!userId && isLoading && !data) ||
+ activeGroupMissingFromFreshGroups
+ ) {
+ return ;
+ }
+
return <>{children}>;
}
diff --git a/src/platform/src/shared/services/analyticsService.ts b/src/platform/src/shared/services/analyticsService.ts
index 1c60a542e6..6aa38ed74e 100644
--- a/src/platform/src/shared/services/analyticsService.ts
+++ b/src/platform/src/shared/services/analyticsService.ts
@@ -8,12 +8,63 @@ import { syncClientSessionToken } from './sessionAuthToken';
import type {
AnalyticsChartRequest,
AnalyticsChartResponse,
+ RecentReading,
RecentReadingRequest,
RecentReadingsResponse,
DataDownloadRequest,
DataDownloadResponse,
} from '../types/api';
+const RECENT_READINGS_BATCH_SIZE = 2;
+
+type RecentReadingsPayload =
+ | RecentReadingsResponse
+ | {
+ success?: boolean;
+ message?: string;
+ measurements?: RecentReading[];
+ data?: RecentReading[] | { measurements?: RecentReading[] };
+ };
+
+const normalizeRecentReadingsResponse = (
+ payload: RecentReadingsPayload,
+ fallbackMessage: string
+): RecentReadingsResponse => {
+ const nestedData = 'data' in payload ? payload.data : undefined;
+ const measurements = Array.isArray(payload.measurements)
+ ? payload.measurements
+ : Array.isArray(nestedData)
+ ? nestedData
+ : nestedData && Array.isArray(nestedData.measurements)
+ ? nestedData.measurements
+ : [];
+
+ return {
+ success: payload.success !== false,
+ message: payload.message || fallbackMessage,
+ measurements,
+ };
+};
+
+const isAbortError = (error: unknown): boolean => {
+ const candidate = error as {
+ name?: string;
+ code?: string;
+ message?: string;
+ } | null;
+
+ if (!candidate) {
+ return false;
+ }
+
+ return (
+ candidate.name === 'AbortError' ||
+ candidate.name === 'CanceledError' ||
+ candidate.code === 'ERR_CANCELED' ||
+ candidate.message === 'canceled'
+ );
+};
+
export class AnalyticsService {
private authenticatedClient: ApiClient;
private serverClient: ApiClient;
@@ -47,13 +98,16 @@ export class AnalyticsService {
request: RecentReadingRequest,
signal?: AbortSignal
): Promise {
- const normalizedSiteIds = (request.site_id || '')
- .split(',')
- .map(siteId => siteId.trim())
- .filter(Boolean)
- .join(',');
+ const normalizedSiteIds = Array.from(
+ new Set(
+ (request.site_id || '')
+ .split(',')
+ .map(siteId => siteId.trim())
+ .filter(Boolean)
+ )
+ );
- if (!normalizedSiteIds) {
+ if (normalizedSiteIds.length === 0) {
return {
success: true,
message: 'No site IDs provided',
@@ -61,17 +115,83 @@ export class AnalyticsService {
};
}
- const response = await this.serverClient.get(
- '/devices/readings/recent',
- {
- params: {
- site_id: normalizedSiteIds,
- ...(request.user_id ? { user_id: request.user_id } : {}),
- },
- signal,
- }
+ // The backend can fail on larger comma-joined site_id lists, so keep
+ // recent-readings requests small and merge the results for the caller.
+ const siteIdBatches: string[][] = [];
+
+ for (
+ let index = 0;
+ index < normalizedSiteIds.length;
+ index += RECENT_READINGS_BATCH_SIZE
+ ) {
+ siteIdBatches.push(
+ normalizedSiteIds.slice(index, index + RECENT_READINGS_BATCH_SIZE)
+ );
+ }
+
+ const responses = await Promise.allSettled(
+ siteIdBatches.map(async siteIdBatch => {
+ const response = await this.serverClient.get(
+ '/devices/readings/recent',
+ {
+ params: {
+ site_id: siteIdBatch.join(','),
+ },
+ signal,
+ }
+ );
+
+ return normalizeRecentReadingsResponse(
+ response.data,
+ 'Recent readings fetched successfully'
+ );
+ })
);
- return response.data;
+
+ const abortedResponse = responses.find(
+ (response): response is PromiseRejectedResult =>
+ response.status === 'rejected' &&
+ (signal?.aborted || isAbortError(response.reason))
+ );
+
+ if (abortedResponse) {
+ throw abortedResponse.reason;
+ }
+
+ const successfulResponses = responses
+ .filter(
+ (
+ response
+ ): response is PromiseFulfilledResult =>
+ response.status === 'fulfilled'
+ )
+ .map(response => response.value);
+
+ if (successfulResponses.length === 0) {
+ const failedResponse = responses.find(
+ (response): response is PromiseRejectedResult =>
+ response.status === 'rejected'
+ );
+
+ throw (
+ failedResponse?.reason ||
+ new Error('Failed to fetch recent readings')
+ );
+ }
+
+ return {
+ success:
+ successfulResponses.length === responses.length &&
+ successfulResponses.every(response => response.success !== false),
+ message:
+ successfulResponses
+ .map(response => response.message)
+ .filter(Boolean)
+ .join('; ') || 'Recent readings fetched successfully',
+ measurements: successfulResponses.flatMap(
+ response => response.measurements ?? []
+ ),
+ };
}
// Download data - authenticated endpoint
diff --git a/src/platform/src/shared/services/apiClient.ts b/src/platform/src/shared/services/apiClient.ts
index c18a5d6f87..2dd006cb99 100644
--- a/src/platform/src/shared/services/apiClient.ts
+++ b/src/platform/src/shared/services/apiClient.ts
@@ -15,6 +15,7 @@ const UNAUTHORIZED_EVENT_NAME = 'auth:unauthorized';
const UNAUTHORIZED_EVENT_COOLDOWN_MS = 1500;
const API_FAILURE_NOTIFY_COOLDOWN_MS = 10 * 60 * 1000;
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
+const JWT_REFRESH_SKEW_MS = 2 * 60 * 1000;
const apiFailureNotificationCache = new Map();
@@ -68,6 +69,71 @@ const _refreshAuthToken = async (): Promise<{
};
};
+const decodeBase64Url = (value: string): string | null => {
+ try {
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
+
+ if (typeof window !== 'undefined' && typeof window.atob === 'function') {
+ return window.atob(padded);
+ }
+
+ return Buffer.from(padded, 'base64').toString('utf8');
+ } catch {
+ return null;
+ }
+};
+
+const getJwtExpiryMs = (token: string): number | null => {
+ const [, payload] = token.split('.');
+ if (!payload) {
+ return null;
+ }
+
+ try {
+ const decodedPayload = decodeBase64Url(payload);
+ if (!decodedPayload) {
+ return null;
+ }
+
+ const parsedPayload = JSON.parse(decodedPayload) as { exp?: unknown };
+ return typeof parsedPayload.exp === 'number'
+ ? parsedPayload.exp * 1000
+ : null;
+ } catch {
+ return null;
+ }
+};
+
+const isJwtExpiring = (token: string): boolean => {
+ const expiryMs = getJwtExpiryMs(token);
+ return expiryMs !== null && expiryMs <= Date.now() + JWT_REFRESH_SKEW_MS;
+};
+
+const readAuthorizationToken = (
+ headers: AxiosHeaders | InternalAxiosRequestConfig['headers']
+): string => {
+ const normalizedHeaders = AxiosHeaders.from(headers);
+ const authorizationHeader = normalizedHeaders.get('Authorization');
+ if (typeof authorizationHeader !== 'string') {
+ return '';
+ }
+
+ return normalizeOAuthAccessToken(authorizationHeader);
+};
+
+const dispatchTokenRefreshedEvent = (token: string, expiresIn?: number) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ window.dispatchEvent(
+ new CustomEvent('auth:token-refreshed', {
+ detail: { token, expiresIn },
+ })
+ );
+};
+
// Extended type for config with metadata
type ApiRequestConfig = AxiosRequestConfig & {
suppressErrorLogging?: boolean;
@@ -218,6 +284,37 @@ export class ApiClient {
return `${cleanBaseUrl}/api/v2`;
}
+ private isRefreshRequest(config?: InternalAxiosRequestConfig): boolean {
+ const url = config?.url;
+ return typeof url === 'string' && url.includes('/users/token/refresh');
+ }
+
+ private async refreshAuthHeader(): Promise {
+ if (_isRefreshing) {
+ return new Promise((resolve, reject) => {
+ _pendingQueue.push({ resolve, reject });
+ });
+ }
+
+ _isRefreshing = true;
+
+ try {
+ const { token, expiresIn } = await _refreshAuthToken();
+ const authHeader = `JWT ${token}`;
+
+ this.setAuthToken(token);
+ flushPendingQueue(authHeader);
+ dispatchTokenRefreshedEvent(token, expiresIn);
+
+ return authHeader;
+ } catch (refreshError) {
+ flushPendingQueue(null, refreshError);
+ throw refreshError;
+ } finally {
+ _isRefreshing = false;
+ }
+ }
+
private setupInterceptors() {
// Request interceptor to add auth headers
this.client.interceptors.request.use(
@@ -232,8 +329,35 @@ export class ApiClient {
// API request logging removed to reduce console noise
if (this.authType === AuthType.JWT) {
- // JWT tokens are set via setAuthToken() method
- // The Authorization header should already be set on the client defaults
+ const headers = AxiosHeaders.from(config.headers);
+ let token = readAuthorizationToken(headers);
+
+ if (!token) {
+ const session = await getSession();
+ token = normalizeOAuthAccessToken(
+ (session as { accessToken?: string })?.accessToken || ''
+ );
+
+ if (token) {
+ headers.set('Authorization', `JWT ${token}`);
+ }
+ }
+
+ if (token && isJwtExpiring(token) && !this.isRefreshRequest(config)) {
+ try {
+ const authHeader = await this.refreshAuthHeader();
+ headers.set('Authorization', authHeader);
+ } catch (refreshError) {
+ dispatchUnauthorizedEvent({
+ status: 401,
+ message: 'Session refresh failed',
+ url: config.url,
+ });
+ return Promise.reject(refreshError);
+ }
+ }
+
+ config.headers = headers;
} else if (this.authType === AuthType.API_TOKEN) {
// API token endpoints must never receive Authorization headers.
const headers = AxiosHeaders.from(config.headers);
@@ -326,28 +450,25 @@ export class ApiClient {
}
if (error.response?.status === 401) {
- // 401 Unauthorized - attempt silent refresh for JWT auth
- // Log at warn level for debugging purposes only
- logger.warn('Unauthorized API access', {
- ...errorContext,
- message: 'Session may have expired or insufficient permissions',
- });
-
if (this.authType !== AuthType.JWT) {
+ logger.warn('Unauthorized API access', {
+ ...errorContext,
+ message: 'Session may have expired or insufficient permissions',
+ });
return Promise.reject(error);
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
- const isRefreshCall =
- typeof originalRequest?.url === 'string' &&
- originalRequest.url.includes('/users/token/refresh');
+ const isRefreshCall = this.isRefreshRequest(originalRequest);
if (originalRequest?._retry || isRefreshCall) {
- logger.warn(
- 'Token refresh failed — dispatching unauthorized event'
- );
+ logger.warn('Unauthorized API access', {
+ ...errorContext,
+ message:
+ 'Session refresh failed or user lacks required permissions',
+ });
dispatchUnauthorizedEvent({
status: error.response.status,
message: error.response.statusText || 'unauthorized',
@@ -356,60 +477,27 @@ export class ApiClient {
return Promise.reject(error);
}
- if (_isRefreshing) {
- return new Promise((resolve, reject) => {
- _pendingQueue.push({ resolve, reject });
- })
- .then(authHeader => {
- const headers = AxiosHeaders.from(originalRequest.headers);
- headers.set('Authorization', authHeader);
- originalRequest.headers = headers;
- originalRequest._retry = true;
- return this.client(originalRequest);
- })
- .catch(() => Promise.reject(error));
- }
-
originalRequest._retry = true;
- _isRefreshing = true;
try {
- const { token, expiresIn } = await _refreshAuthToken();
- const authHeader = `JWT ${token}`;
-
- // Update the in-memory axios default so subsequent requests use it
- this.setAuthToken(token);
-
- // Flush queued requests with the new token
- flushPendingQueue(authHeader);
-
- if (typeof window !== 'undefined') {
- window.dispatchEvent(
- new CustomEvent('auth:token-refreshed', {
- detail: { token, expiresIn },
- })
- );
- }
-
+ const authHeader = await this.refreshAuthHeader();
const headers = AxiosHeaders.from(originalRequest.headers);
headers.set('Authorization', authHeader);
originalRequest.headers = headers;
// Retry the original request with the new token
return this.client(originalRequest);
- } catch (refreshError) {
- flushPendingQueue(null, refreshError);
- logger.warn(
- 'Silent token refresh failed — dispatching unauthorized event'
- );
+ } catch {
+ logger.warn('Unauthorized API access', {
+ ...errorContext,
+ message: 'Silent token refresh failed',
+ });
dispatchUnauthorizedEvent({
status: error.response?.status ?? 401,
message: error.response?.statusText || 'unauthorized',
url: error.config?.url,
});
return Promise.reject(error);
- } finally {
- _isRefreshing = false;
}
} else if (error.response?.status === 403) {
// 403 Forbidden - permission issue, log but don't spam Slack
diff --git a/src/platform/src/shared/services/deviceService.ts b/src/platform/src/shared/services/deviceService.ts
index 2cd2c83cc7..f84575820d 100644
--- a/src/platform/src/shared/services/deviceService.ts
+++ b/src/platform/src/shared/services/deviceService.ts
@@ -467,16 +467,39 @@ export class DeviceService {
}
await this.ensureAuthenticated();
- const response = await this.authenticatedClient.get<
- CohortResponse | ApiErrorResponse
- >(`${DEVICE_COHORTS_PATH}/${resolvedCohortId}`, { signal });
- const data = response.data;
- if ('success' in data && !data.success) {
- throw new Error(data.message || 'Failed to get cohort details');
- }
+ try {
+ const response = await this.authenticatedClient.get<
+ CohortResponse | ApiErrorResponse
+ >(`${DEVICE_COHORTS_PATH}/${resolvedCohortId}`, {
+ signal,
+ suppressErrorLogging: true,
+ });
+ const data = response.data;
+
+ if ('success' in data && !data.success) {
+ throw new Error(data.message || 'Failed to get cohort details');
+ }
+
+ return data as CohortResponse;
+ } catch (error) {
+ if (!shouldFallbackToLegacyCohortEndpoint(error)) {
+ throw error;
+ }
- return data as CohortResponse;
+ return {
+ success: true,
+ message: 'Cohort details endpoint unavailable; continuing without details',
+ meta: {
+ total: 0,
+ limit: 0,
+ skip: 0,
+ page: 1,
+ totalPages: 1,
+ },
+ cohorts: [],
+ };
+ }
}
// Get grids summary - authenticated endpoint
@@ -570,16 +593,12 @@ export class DeviceService {
// Get map readings - API token endpoint
async getMapReadingsWithToken(
cohort_id?: string,
- signal?: AbortSignal,
- user_id?: string
+ signal?: AbortSignal
): Promise {
const params: Record = {};
if (cohort_id) {
params.cohort_id = cohort_id;
}
- if (user_id) {
- params.user_id = user_id;
- }
const response = await this.serverClient.get<
MapReadingsResponse | ApiErrorResponse
diff --git a/src/platform/src/shared/types/api.ts b/src/platform/src/shared/types/api.ts
index 5dd34fb230..c4316a8fe3 100644
--- a/src/platform/src/shared/types/api.ts
+++ b/src/platform/src/shared/types/api.ts
@@ -1234,7 +1234,6 @@ export interface DataDownloadResponse {
// Recent readings types
export interface RecentReadingRequest {
site_id: string; // comma-separated site IDs
- user_id?: string;
}
export interface AQIRanges {
diff --git a/src/vertex/README.md b/src/vertex/README.md
index b93c13aea4..52e53f4caf 100644
--- a/src/vertex/README.md
+++ b/src/vertex/README.md
@@ -1,4 +1,4 @@
-# Vertex (Web App)
+# Vertex (Web App).
`vertex` is the AirQo web application for Device and Network management.
diff --git a/src/vertex/app/(authenticated)/admin/cohorts/[id]/page.tsx b/src/vertex/app/(authenticated)/admin/cohorts/[id]/page.tsx
index 97d0598ce0..29b204c19e 100644
--- a/src/vertex/app/(authenticated)/admin/cohorts/[id]/page.tsx
+++ b/src/vertex/app/(authenticated)/admin/cohorts/[id]/page.tsx
@@ -15,11 +15,12 @@ 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(4)].map((_, i) => (
+
+ {[...Array(3)].map((_, i) => (
))}
@@ -31,7 +32,9 @@ export default function CohortDetailsPage() {
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;
@@ -103,9 +106,9 @@ export default function CohortDetailsPage() {
{String((error as Error)?.message || "Unknown error")}
) : (
-
- {/* Cohort basic info card */}
-
+
+ {/* Cards section */}
+
+
{/* Devices list */}
diff --git a/src/vertex/app/(authenticated)/cohorts/page.tsx b/src/vertex/app/(authenticated)/cohorts/page.tsx
index e1f4e28bc5..e5ba98aa4a 100644
--- a/src/vertex/app/(authenticated)/cohorts/page.tsx
+++ b/src/vertex/app/(authenticated)/cohorts/page.tsx
@@ -2,15 +2,17 @@
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 } from "@/core/hooks/useCohorts";
+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 = {
@@ -23,33 +25,49 @@ type CohortRow = {
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 { userDetails: user, isExternalOrg, activeGroup } = useUserContext();
+ const { isExternalOrg, activeGroup, userScope } = useUserContext();
- // 1. Fetch group cohort IDs if external org
- const { data: groupCohortIds, isLoading: isLoadingGroupCohorts } = useGroupCohorts(
- activeGroup?._id,
- { enabled: isExternalOrg && !!activeGroup?._id }
- );
+ 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,
+ });
- // 2. Determine target IDs based on scope
+ // Resolve effective cohort IDs based on scope
const targetCohortIds = useMemo(() => {
- if (isExternalOrg) {
- return groupCohortIds || [];
- }
- // Personal scope
- return user?.cohort_ids || [];
- }, [isExternalOrg, groupCohortIds, user?.cohort_ids]);
+ if (isPersonalScope) return personalCohortIds;
+ if (isExternalOrg) return groupCohortIds || [];
+ return [];
+ }, [isPersonalScope, personalCohortIds, isExternalOrg, groupCohortIds]);
+
+ const isResolvingIds =
+ (isPersonalScope && isLoadingPersonalCohorts) ||
+ (isExternalOrg && !isPersonalScope && isLoadingGroupCohorts);
- // 3. Conditional Fetching
- const hasIdsToFetch = targetCohortIds && targetCohortIds.length > 0;
- const shouldFetchCohorts = hasIdsToFetch && !(isExternalOrg && isLoadingGroupCohorts);
+ const hasIdsToFetch = targetCohortIds.length > 0;
+ const shouldFetchCohorts = hasIdsToFetch && !isResolvingIds;
const { cohorts, meta, isFetching: isFetchingCohorts, error } = useCohorts(
{
@@ -65,13 +83,12 @@ export default function CohortsPage() {
const pageCount = meta?.totalPages ?? 0;
- const isDeterminingIds = isExternalOrg && isLoadingGroupCohorts;
- const isLoading = isExternalOrg ? (isLoadingGroupCohorts || isFetchingCohorts) : isFetchingCohorts;
- const showEmptyState = !isLoading && (!hasIdsToFetch || (cohorts && cohorts.length === 0));
-
- const tableLoading = isDeterminingIds || (hasIdsToFetch && isFetchingCohorts);
-
+ 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,
@@ -118,7 +135,7 @@ export default function CohortsPage() {
- )
+ );
}
return (
@@ -133,13 +150,13 @@ export default function CohortsPage() {
{
const row = item as CohortRow;
- if (row?.id) router.push(`/cohorts/${row.id}`)
+ if (row?.id) router.push(`/cohorts/${row.id}`);
}}
emptyState={displayError ? (displayError.message || "Unable to load cohorts") : "No cohorts available"}
serverSidePagination
@@ -156,4 +173,4 @@ export default function CohortsPage() {
);
-}
+}
\ No newline at end of file
diff --git a/src/vertex/app/(authenticated)/devices/claim/page.tsx b/src/vertex/app/(authenticated)/devices/claim/page.tsx
deleted file mode 100644
index bbd7ce2951..0000000000
--- a/src/vertex/app/(authenticated)/devices/claim/page.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import {
- Smartphone,
- FileSpreadsheet,
- Wifi,
- QrCode,
- HelpCircle,
- CheckCircle2,
- Database
-} from "lucide-react";
-import ClaimDeviceModal, { FlowStep } from "@/components/features/claim/claim-device-modal";
-import { useUserContext } from "@/core/hooks/useUserContext";
-
-const DeviceClaimingPage = () => {
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [initialStep, setInitialStep] = useState
('method-select');
- const { isPersonalContext, activeGroup } = useUserContext();
-
- const handleOpenModal = (step: FlowStep) => {
- setInitialStep(step);
- setIsModalOpen(true);
- };
-
- const getDescriptionText = () => {
- if (isPersonalContext) {
- return "Add devices to your personal account. Once added, you can easily transfer them to your organization's workspace.";
- }
-
- const groupName = activeGroup?.grp_title || "your organization";
- return `Add devices to ${groupName} organization. You will be able to deploy, monitor online status and more.`;
- };
-
- return (
-
-
-
- {/* Header Section */}
-
-
- Claim Your Devices
-
-
- {getDescriptionText()}
-
-
-
- {/* Main Action Cards - The "Intent" Selection */}
-
- {/* Option A: Single Device (Smartphone/QR Context) */}
-
handleOpenModal('qr-scan')}
- className="flex flex-col text-left p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-blue-500 dark:hover:border-blue-400 hover:shadow-md transition-all group"
- >
-
-
-
-
- Add Single Device
-
-
- Best for setting up a new monitor at home or a specific site.
- Scan the QR code or enter the ID manually.
-
-
- Start Setup →
-
-
-
- {/* Option B: Bulk Import (File/Office Context) */}
-
handleOpenModal('bulk-input')}
- className="flex flex-col text-left p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-green-500 dark:hover:border-green-400 hover:shadow-md transition-all group"
- >
-
-
-
-
- Bulk Import
-
-
- Ideal for organizations deploying a fleet.
- Upload a CSV file or enter multiple IDs at once.
-
-
- Import Batch →
-
-
-
- {/* Option C: Import from Cohort */}
-
handleOpenModal('cohort-import')}
- className="flex flex-col text-left p-8 bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 hover:border-violet-500 dark:hover:border-violet-400 hover:shadow-md transition-all group"
- >
-
-
-
-
- Import from Cohort
-
-
- Connect pre-provisioned device groups.
- Enter a Cohort ID to setup multiple devices instantly.
-
-
- Import →
-
-
-
-
- {/* Onboarding / Preparation Section */}
-
-
- {/* Checklist */}
-
-
-
- Before you start
-
-
-
-
-
- Check WiFi Connectivity
- Ensure the installation site has reliable 2.4GHz WiFi coverage.
-
-
-
-
-
- Locate Device Label
- Have the physical device or the box ready to scan the QR code.
-
-
-
-
-
- {/* Support Links */}
- {/* TODO: Create documentation pages for device claiming help resources */}
-
-
-
- Need Help?
-
-
-
- {/* TODO: Add link to Device ID location guide */}
-
- Where do I find the Device ID?
-
-
-
- {/* TODO: Add link to CSV template download */}
-
- Download Bulk Import Template
-
-
-
- {/* TODO: Add link to support contact form or email */}
-
- Contact Support
-
-
-
-
-
-
-
- {/* The Modal handles the actual logic */}
-
setIsModalOpen(false)}
- initialStep={initialStep}
- />
-
- );
-};
-
-export default DeviceClaimingPage;
\ No newline at end of file
diff --git a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx
index 81499a1b40..d8688e08ab 100644
--- a/src/vertex/app/(authenticated)/devices/my-devices/page.tsx
+++ b/src/vertex/app/(authenticated)/devices/my-devices/page.tsx
@@ -1,10 +1,9 @@
"use client";
import React, { useState } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import { AqCollocation, AqPlus } from "@airqo/icons-react";
import { Upload } from "lucide-react";
-import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { useMyDevices, useDevices } from "@/core/hooks/useDevices";
import { useAppSelector } from "@/core/redux/hooks";
@@ -13,16 +12,22 @@ 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 router = useRouter();
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();
@@ -98,10 +103,13 @@ const MyDevicesPage = () => {
-
router.push("/devices/claim")}>
-
- Claim Device
-
+
setIsClaimModalOpen(true)}
+ Icon={AqPlus}
+ permission={PERMISSIONS.DEVICE.CLAIM}
+ >
+ Add AirQo Device
+
setImportDeviceOpen(true)}
@@ -157,11 +165,12 @@ const MyDevicesPage = () => {
router.push("/devices/claim")}
+ onClick={() => setIsClaimModalOpen(true)}
disabled={isLoading}
Icon={AqPlus}
+ permission={PERMISSIONS.DEVICE.CLAIM}
>
- Claim AirQo Device
+ Add AirQo Device
{
open={isImportDeviceOpen}
onOpenChange={setImportDeviceOpen}
/>
+ setIsClaimModalOpen(false)}
+ />
import("@/components/features/claim/claim-device-modal"),
+ { ssr: false }
+);
import ReusableButton from "@/components/shared/button/ReusableButton";
export default function DevicesPage() {
- const router = useRouter();
const [isImportDeviceOpen, setImportDeviceOpen] = useState(false);
+ const [isClaimModalOpen, setIsClaimModalOpen] = useState(false);
// Permission checks
const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE);
@@ -31,11 +36,11 @@ export default function DevicesPage() {
router.push("/devices/claim")}
+ onClick={() => setIsClaimModalOpen(true)}
Icon={Plus}
permission={PERMISSIONS.DEVICE.CLAIM}
>
- Claim AirQo Device
+ Add AirQo Device
+ setIsClaimModalOpen(false)}
+ />
diff --git a/src/vertex/app/(authenticated)/home/page.tsx b/src/vertex/app/(authenticated)/home/page.tsx
index b19050fdd7..d17da88564 100644
--- a/src/vertex/app/(authenticated)/home/page.tsx
+++ b/src/vertex/app/(authenticated)/home/page.tsx
@@ -9,12 +9,52 @@ 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 HomeEmptyState from "@/components/features/home/HomeEmptyState";
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 { formatTitle } from "@/components/features/org-picker/organization-picker";
+import ReusableToast from "@/components/shared/toast/ReusableToast";
+
+// ─── 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 = () => (
@@ -39,13 +79,13 @@ const StatsSkeleton = () => (
);
-// Lazy load stats cards
+// ─── Lazy-loaded heavy components ─────────────────────────────────────────────
+
const DashboardStatsCards = dynamic(
- () => import("@/components/features/dashboard/stats-cards").then(mod => ({ default: mod.DashboardStatsCards })),
- {
- ssr: false,
- loading: () => ,
- }
+ () => import("@/components/features/dashboard/stats-cards").then((mod) => ({
+ default: mod.DashboardStatsCards,
+ })),
+ { ssr: false, loading: () => }
);
const ClaimDeviceModal = dynamic(
@@ -58,42 +98,212 @@ const ImportDeviceModal = dynamic(
{ 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 {
+ 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 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;
+
+ // ── Checklist state (frontend-only, localStorage-backed) ──────────────────
+ const [checklistState, setChecklistState] = React.useState(() =>
+ getChecklistState(orgId ?? "")
+ );
+
+ // Re-sync when the active workspace changes
+ React.useEffect(() => {
+ if (orgId) {
+ setChecklistState(getChecklistState(orgId));
+ }
+ }, [orgId]);
+
+ const updateChecklist = React.useCallback(
+ (patch: Partial<{ completedSteps: string[]; dismissed: boolean }>) => {
+ setChecklistState((prev) => {
+ const next = { ...prev, ...patch };
+ if (orgId) saveChecklistState(orgId, next);
+ return next;
+ });
+ },
+ [orgId]
+ );
+
+ const openAddDeviceChoice = React.useCallback(() => {
+ setIsAddDeviceChoiceOpen(true);
+ }, []);
+
+ // Scroll to and highlight the Device Visibility accordion section
+ 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({
+ completedSteps: Array.from(
+ new Set([...(checklistState.completedSteps || []), "add-device", "assign-cohort"]),
+ ),
+ });
+ } else {
+ if (deviceInfo?.deviceId) {
+ setNewlyClaimedDevice([{ _id: deviceInfo.deviceId, name: deviceInfo.deviceName || "", long_name: deviceInfo.deviceName || "" }]);
+ }
+ updateChecklist({
+ completedSteps: Array.from(
+ new Set([...(checklistState.completedSteps || []), "add-device"]),
+ ),
+ });
+ }
+ },
+ [checklistState.completedSteps, refreshHomeData, updateChecklist]
+ );
+ const handleCohortAssigned = React.useCallback(() => {
+ updateChecklist({
+ completedSteps: Array.from(
+ new Set([...(checklistState.completedSteps || []), "assign-cohort"]),
+ ),
+ });
+ setNewlyClaimedDevice(undefined);
+ }, [checklistState.completedSteps, updateChecklist]);
+
+ // ── Permissions ────────────────────────────────────────────────────────────
const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE];
const permissionsMap = usePermissions(permissionsToCheck);
+ const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE];
- const userId = (session?.user as { id?: string })?.id || user?._id;
-
+ // ── Device data ────────────────────────────────────────────────────────────
const { devices: groupDevices, isLoading: isLoadingGroupDevices } = useDevices({
limit: 1,
- enabled: userScope === 'organisation',
+ enabled: userScope === "organisation",
});
const { data: myDevicesData, isLoading: isLoadingMyDevices } = useMyDevices(
userId || "",
undefined,
- {
- enabled: !!userId && userScope === 'personal',
- }
+ { enabled: !!userId && userScope === "personal" }
);
- // ============================================================
- // EARLY RETURNS - All checks happen BEFORE any UI is rendered
- // ============================================================
+ // 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.
+ React.useEffect(() => {
+ 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 autoSteps: string[] = [];
+ if (hasDevices) {
+ autoSteps.push("add-device", "assign-cohort");
+ } else if (hasCohorts) {
+ autoSteps.push("assign-cohort");
+ }
+
+ if (autoSteps.length === 0) return;
+
+ setChecklistState((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, next);
+ return next;
+ });
+ }, [orgId, userScope, myDevicesData, groupDevices, personalCohortIds, groupCohortIds]);
+
+ // ── Early returns ──────────────────────────────────────────────────────────
- // 1. Error state - check first
if (hasError) {
return (
@@ -105,131 +315,275 @@ const WelcomePage = () => {
);
}
- // 2. Loading state - show loading UI while data is being fetched
+ // ── 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 = checklistState.completedSteps.length >= TOTAL_STEPS;
+ const showChecklist = !allStepsComplete && !checklistState.dismissed;
+
+ const showClaimDevice = (() => {
+ switch (userContext) {
+ case "personal":
+ case "external-org":
+ default:
+ return true;
+ }
+ })();
+
+ const renderSharedModals = () => (
+ <>
+ {/* Always rendered — controlled by open prop so sibling positions stay stable */}
+
{
+ 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;
+ (userScope === "personal" && isLoadingMyDevices) ||
+ (userScope === "organisation" && isLoadingGroupDevices) ||
+ isLoadingUserContext;
if (isLoading) {
return (
-
- {/* Context Header Skeleton */}
-
-
-
-
+ <>
+
- {/* Stats Cards Skeleton */}
-
-
-
- {/* Quick Access Skeleton */}
-
-
-
- {[1, 2, 3].map((i) => (
-
- ))}
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
-
+ {renderSharedModals()}
+ >
);
}
- // 3. Empty state - check AFTER loading is complete but BEFORE main UI
- const hasNoDevices = userScope === 'personal'
- ? (myDevicesData?.devices || []).length === 0
- : groupDevices.length === 0;
- if (hasNoDevices) {
- return
;
- }
- // ============================================================
- // MAIN UI - Only renders when we have data to show
- // ============================================================
+ const renderMainContent = () => {
+ if (hasNoDevices) {
+ return (
+
+
+ {showChecklist && (
+ updateChecklist({ dismissed: true })}
+ onAddDevice={openAddDeviceChoice}
+ onGoToCohorts={() => setIsAssignCohortModalOpen(true)}
+ onGoToVisibility={handleGoToVisibility}
+ onMarkAsDone={() => {}}
+ organizationName={formatTitle(activeGroup?.grp_title || "")}
+ isReadOnly={!canClaimDevice}
+ />
+ )}
+
+ );
+ }
- // Filter actions based on context
- const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE];
+ return (
+
+
- // Determine if action is visible in current context
- const showClaimDevice = (() => {
- switch (userContext) {
- case "personal":
- return true;
- case "external-org":
- return true;
- default:
- return true;
- }
- })();
+ {showChecklist && (
+
updateChecklist({ dismissed: true })}
+ onAddDevice={openAddDeviceChoice}
+ onGoToCohorts={() => setIsAssignCohortModalOpen(true)}
+ onGoToVisibility={handleGoToVisibility}
+ onMarkAsDone={() => {}}
+ organizationName={formatTitle(activeGroup?.grp_title || "")}
+ isReadOnly={!canClaimDevice}
+ />
+ )}
- return (
-
-
-
- {showClaimDevice && (
-
-
Home
-
-
setIsClaimModalOpen(true)}
- Icon={Plus}
+ {showClaimDevice && (
+
+
Home
+
+ setIsClaimModalOpen(true)}
+ Icon={Plus}
+ >
+ Add AirQo Device
+
+ setIsImportModalOpen(true)}
+ Icon={Upload}
+ >
+ Import External Device
+
+
+
+ )}
+
+
+
+
- Claim AirQo Device
-
- setIsImportModalOpen(true)}
- Icon={Upload}
+
+ Device Health
+
+
+
+
+
+
+
- Import External Device
-
-
-
- )}
-
-
-
-
-
- Device Health
-
-
-
-
-
-
- {userContext === 'external-org' && (
-
Device Visibility
-
-
+
+ {
+ const nextCompletedSteps = Array.from(
+ new Set([...(checklistState.completedSteps || []), "set-visibility"])
+ );
+ const justCompletedSetup =
+ !checklistState.completedSteps.includes("set-visibility") &&
+ nextCompletedSteps.length >= TOTAL_STEPS;
+
+ updateChecklist({
+ completedSteps: nextCompletedSteps,
+ });
+
+ if (justCompletedSetup) {
+ ReusableToast({
+ message: "Workspace setup complete. You're ready to monitor and manage your devices.",
+ type: "SUCCESS",
+ });
+ }
+ }}
+ />
- )}
-
+
+
+
+ {userId && (user?.email || user?.userName) && (
+
+ )}
+ );
+ };
-
setIsClaimModalOpen(false)}
- />
-
- {userId && (user?.email || user?.userName) && (
-
- )}
-
+ return (
+ <>
+ {renderMainContent()}
+ {renderSharedModals()}
+ >
);
};
-export default WelcomePage;
\ No newline at end of file
+export default WelcomePage;
diff --git a/src/vertex/app/_docs/internal/RBAC-Feature-Access.md b/src/vertex/app/_docs/internal/RBAC-Feature-Access.md
index bfbf21faad..ecc2771ed1 100644
--- a/src/vertex/app/_docs/internal/RBAC-Feature-Access.md
+++ b/src/vertex/app/_docs/internal/RBAC-Feature-Access.md
@@ -71,7 +71,7 @@
| **Feature/Screen** | **Permission** | **Disable for** | **Hide for** |
|----------------------------|-------------------------------|--------------------------------|--------------------------------------|
-| Claim Device | DEVICE.UPDATE | All except permitted roles | Guest/No Org |
+| 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 |
diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md
index f334890af6..298969e18c 100644
--- a/src/vertex/app/changelog.md
+++ b/src/vertex/app/changelog.md
@@ -3,6 +3,137 @@
> **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend.
---
+
+## Version 1.23.58
+**Released:** June 03, 2026
+
+### Personal Devices Claiming & Cohort Management Enhancements
+
+Introduced comprehensive enhancements to personal device claiming workflows, onboarding experiences, and cohort management. This includes guided device claiming, bulk imports for cohorts, personal user cohorts, and UI refinements to improve the onboarding experience for personal users.
+
+
+Personal Device Claiming & Onboarding (5)
+
+- **Guided Claiming Modes**: Introduced guided and fast claim modes, refactoring the claim device modal into clear step-by-step processes for better usability.
+- **Onboarding Checklist**: Added an interactive onboarding checklist to the Home page to guide new users, preserving completion statuses across sessions.
+- **Device Choice Dialog**: Added a device choice dialog during onboarding to simplify the initial device setup and integration.
+- **Read-Only Onboarding UI**: Polished the onboarding interface with read-only views where appropriate to prevent accidental edits during the setup phase.
+- **UI Refinements**: Redesigned the organization picker with alphabetical sorting, added an Administrator pill tag to the topbar logo, removed obsolete organization setup banners, and removed the deprecated "Download for Windows" button from the admin sidebar.
+
+
+
+
+Cohort & Bulk Import Improvements (4)
+
+- **Personal User Cohorts & Refactor**: Added dedicated APIs, hooks, and UI support for personal cohorts to isolate and manage user-specific device groupings effectively. Refactored the `CohortOrganizationsCard` to use a cleaner layout, displaying a compact view of up to 3 organizations with a full searchable modal table for extended lists.
+- **Cohort Imports**: Enabled importing capabilities directly for cohorts, setting cohort import as a primary option, and implemented robust validation to handle cohort verification errors gracefully.
+- **Bulk Import Enhancements**: Fixed bulk import bugs and enhanced the bulk import flow to reliably display import results, providing detailed previews and success summaries.
+
+
+
+
+Claim Page Migration & UI Polish (6)
+
+- **Modal-Based Claim Flow**: Deleted the standalone `/devices/claim` route and completely migrated the device claiming workflow to the `ClaimDeviceModal`.
+- **Global Modal Wiring**: Updated actions across My Devices, Organization Overview, Device Assignment, and the Deploy Device Wizard to trigger the claim modal dynamically in-place instead of navigating.
+- **Routing & Navigation Cleanup**: Removed defunct `/devices/claim` references from context-aware routing, recent visits tracking, and the secondary sidebar configuration.
+- **Read-Only Visibility UI**: Updated the Network Visibility card to render for users lacking edit permissions, keeping the current sharing state visible in read-only workspaces.
+- **Dynamic Environment Links**: Updated fallback analytics links (e.g. Sign Up) to dynamically resolve to `staging-analytics` or production based on the environment.
+- **Dynamic Import Loading Polish**: Optimized modal loading states by replacing large skeleton loaders with a refined spinner to reduce layout thrashing.
+
+
+
+
+Session Management & Onboarding (2)
+
+- **Idle Session Timeout**: Increased the inactivity auto-logout threshold from 30 minutes to 6 hours (`INACTIVITY_LIMIT`) in the frontend `authProvider` to accommodate longer-running operations and reduce login friction during daily use.
+- **Cookie Banner Persistence**: Whitelisted the cookie consent flag (`vertex_cookies_accepted`) in the secure session manager so that the cookie banner preference survives user logouts, preventing it from repeatedly nagging users on the login screen.
+
+
+
+
+Files Added & Modified (47)
+
+**Files Deleted:**
+- `src/vertex/app/(authenticated)/devices/claim/page.tsx` [DELETED]
+
+
+**Files Added:**
+- `src/vertex/components/features/claim/steps/BulkInputStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/CohortAssignmentBanner.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/CohortImportStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/ConfirmationSteps.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/ManualInputStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/MethodSelectStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/QRScanStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/steps/SuccessStep.tsx` [ADDED]
+- `src/vertex/components/features/claim/utils.ts` [ADDED]
+- `src/vertex/components/features/cohorts/cohort-organizations-card.tsx` [ADDED]
+- `src/vertex/components/features/cohorts/unassign-cohort-from-group.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/BulkImportForm.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/BulkResultsStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/CohortSelectionStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/ConfirmationStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/FieldMappingStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/ImportMethodSelectStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/ImportPreviewStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/ImportSuccessStep.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/SingleImportForm.tsx` [ADDED]
+- `src/vertex/components/features/devices/import-steps/types.ts` [ADDED]
+- `src/vertex/components/features/home/onboarding-checklist.tsx` [ADDED]
+
+**Files Modified:**
+- `src/vertex/app/(authenticated)/admin/cohorts/[id]/page.tsx` [MODIFIED]
+- `src/vertex/app/(authenticated)/home/page.tsx` [MODIFIED]
+- `src/vertex/components/features/auth/cookie-info-banner.tsx` [MODIFIED]
+- `src/vertex/components/features/claim/claim-device-modal.tsx` [MODIFIED]
+- `src/vertex/components/features/cohorts/assign-cohort-devices.tsx` [MODIFIED]
+- `src/vertex/components/features/cohorts/create-cohort.tsx` [MODIFIED]
+- `src/vertex/components/features/devices/deploy-device-component.tsx` [MODIFIED]
+- `src/vertex/components/features/devices/device-assignment-modal.tsx` [MODIFIED]
+- `src/vertex/components/features/devices/import-device-modal.tsx` [MODIFIED]
+- `src/vertex/components/features/home/network-visibility-card.tsx` [MODIFIED]
+- `src/vertex/components/features/org-picker/organization-picker.tsx` [MODIFIED]
+- `src/vertex/components/layout/primary-sidebar.tsx` [MODIFIED]
+- `src/vertex/components/layout/secondary-sidebar.tsx` [MODIFIED]
+- `src/vertex/components/layout/topbar.tsx` [MODIFIED]
+- `src/vertex/core/apis/cohorts.ts` [MODIFIED]
+- `src/vertex/core/auth/authProvider.tsx` [MODIFIED]
+- `src/vertex/core/hooks/useCohorts.ts` [MODIFIED]
+- `src/vertex/core/hooks/useContextAwareRouting.ts` [MODIFIED]
+- `src/vertex/core/hooks/useRecentlyVisited.ts` [MODIFIED]
+- `src/vertex/core/routes.ts` [MODIFIED]
+- `src/vertex/core/urls.tsx` [MODIFIED]
+- `src/vertex/core/utils/sessionManager.ts` [MODIFIED]
+
+
+
+---
+
+## Version 1.23.57
+**Released:** June 02, 2026
+
+### Organization Picker Banner Migration
+
+Replaced the `ReusableToast` error call in `OrganizationPicker` with the centralized `useBannerWithDelay` hook. The shimmer progress bar dispatch (`setOrganizationSwitching`) was also moved to just before navigation fires so it only appears when the switch is actually proceeding — errors that occur before navigation never trigger the shimmer.
+
+
+Changes (2)
+
+- **Error Feedback**: Replaced `ReusableToast` with `showBannerWithDelay` (`scoped: false`) for org switch failures. Using the delayed variant ensures the banner is not cleared by `ReusableDialog`'s `hideBanner` cleanup effect that fires when the modal closes.
+- **Shimmer Timing Fix**: Moved `setOrganizationSwitching({ isSwitching: true })` inside the try block just before navigation so the progress bar only appears when switching is guaranteed to proceed. The catch block no longer needs to reset it.
+
+
+
+
+Files Updated (1)
+
+- `src/vertex/components/features/org-picker/organization-picker.tsx` [MODIFIED]
+
+
+
+---
+
## Version 1.23.56
**Released:** May 29, 2026
@@ -1655,6 +1786,15 @@ Restricted cohort imports ('airqo') and improved UI accessibility and validation
+
+Device Management Enhancements (3)
+
+- **Add Device Flow**: Refactored the monolithic Add Device modal into distinct, modular steps (guided vs. fast mode), simplifying the overall onboarding experience for new hardware setups.
+- **Smart Device Updating**: Consolidated device update actions into a single smart "Save" button in the device details modal that automatically routes to local or global (ThingSpeak) saving based on the device's sensor manufacturer.
+- **Improved Validation**: Strengthened form validation and error handling across device creation and editing workflows.
+
+
+
UI/UX Enhancements (2)
@@ -2217,11 +2357,11 @@ Overhauled the Home dashboard to improve usability and implemented strict logic
Feature Updates (3)
- **Dashboard Redesign**:
- - Moved primary actions ("Claim AirQo Device", "Import Device") to a dedicated header bar for visibility.
+ - Moved primary actions ("Add AirQo Device", "Import Device") to a dedicated header bar for visibility.
- Added a **"Device Health"** accordion containing Refactored Stats Cards.
- Added a dismissable "Welcome" context header to save screen space.
- Removed the bottom "Quick Access" section.
-- **Global Claim Modal**: The "Claim AirQo Device" button now opens the claim modal directly on the dashboard instead of redirecting to a separate page.
+- **Global Claim Modal**: The "Add AirQo Device" button now opens the claim modal directly on the dashboard instead of redirecting to a separate page.
- **Network Visibility**: Dedicated **Visibility Card** (External Orgs) with clear "Public" vs "Private" toggle states and safety confirmation.
diff --git a/src/vertex/app/globals.css b/src/vertex/app/globals.css
index 3cf49209f8..96cdca0c58 100644
--- a/src/vertex/app/globals.css
+++ b/src/vertex/app/globals.css
@@ -96,7 +96,7 @@ body,
html {
color: rgb(var(--foreground));
background: rgb(var(--background));
- font-family: 'Inter', Arial, Helvetica, sans-serif;
+ font-family: var(--font-inter), Arial, Helvetica, sans-serif;
}
.dark body,
@@ -505,3 +505,39 @@ html {
}
}
+@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/app/layout.tsx b/src/vertex/app/layout.tsx
index b7b1d1ef95..9c4dede794 100644
--- a/src/vertex/app/layout.tsx
+++ b/src/vertex/app/layout.tsx
@@ -10,6 +10,7 @@ import logger from '@/lib/logger';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
+ variable: '--font-inter',
});
export const metadata: Metadata = {
@@ -73,7 +74,7 @@ export default async function RootLayout({
}
return (
-
+
diff --git a/src/vertex/app/types/cohorts.ts b/src/vertex/app/types/cohorts.ts
index bd8455cbf1..77717c7276 100644
--- a/src/vertex/app/types/cohorts.ts
+++ b/src/vertex/app/types/cohorts.ts
@@ -30,6 +30,12 @@ export interface GroupCohortsResponse {
data: string[];
}
+export interface PersonalUserCohortsResponse {
+ success: boolean;
+ message: string;
+ cohorts: string[];
+}
+
export interface OriginalCohortResponse {
success: boolean;
message: string;
diff --git a/src/vertex/app/types/groups.ts b/src/vertex/app/types/groups.ts
index 6c2e58bd25..e84e0a7715 100644
--- a/src/vertex/app/types/groups.ts
+++ b/src/vertex/app/types/groups.ts
@@ -14,4 +14,12 @@ export interface Group {
numberOfGroupUsers: number
grp_users: UserDetails[]
grp_manager: UserDetails
+ cohorts?: string[]
+ organization_slug?: string
+}
+
+export interface CohortGroupsResponse {
+ success: boolean;
+ message: string;
+ groups: Group[];
}
diff --git a/src/vertex/components/features/auth/cookie-info-banner.tsx b/src/vertex/components/features/auth/cookie-info-banner.tsx
index 17deb7f897..3c67976380 100644
--- a/src/vertex/components/features/auth/cookie-info-banner.tsx
+++ b/src/vertex/components/features/auth/cookie-info-banner.tsx
@@ -9,14 +9,14 @@ export function CookieInfoBanner() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
- const cookiesAccepted = localStorage.getItem('cookiesAccepted');
+ const cookiesAccepted = localStorage.getItem('vertex_cookies_accepted');
if (!cookiesAccepted) {
setIsVisible(true);
}
}, []);
const handleDismiss = () => {
- localStorage.setItem('cookiesAccepted', 'true');
+ localStorage.setItem('vertex_cookies_accepted', 'true');
setIsVisible(false);
};
diff --git a/src/vertex/components/features/claim/claim-device-modal.tsx b/src/vertex/components/features/claim/claim-device-modal.tsx
index 6cc63361cc..86e8485d85 100644
--- a/src/vertex/components/features/claim/claim-device-modal.tsx
+++ b/src/vertex/components/features/claim/claim-device-modal.tsx
@@ -1,1059 +1,984 @@
+// ClaimDeviceModal.tsx
'use client';
-import React, { useState, useCallback, useEffect, Component, ReactNode } from 'react';
-import { CheckCircle2, Loader2, AlertCircle, Smartphone, FileSpreadsheet, Database, Plus } from 'lucide-react';
+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 ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
-import { Form, FormField } from '@/components/ui/form';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+
import { useAppSelector } from '@/core/redux/hooks';
import { useRouter } from 'next/navigation';
-import { QRScanner } from '../devices/qr-scanner';
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 { cohorts as cohortsApi } from '@/core/apis/cohorts';
-import logger from '@/lib/logger';
-import { FileUploadParser } from './FileUploadParser';
-import { DeviceEntryRow } from './DeviceEntryRow';
-import { BulkClaimResults } from './BulkClaimResults';
-import ReusableButton from '@/components/shared/button/ReusableButton';
-import { Device } from '@/app/types/devices';
-
-interface QRScannerErrorBoundaryProps {
- children: ReactNode;
- onError?: () => void;
-}
+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';
-interface QRScannerErrorBoundaryState {
- hasError: boolean;
-}
+// ============================================================
+// MODES
+// ============================================================
-class QRScannerErrorBoundary extends Component {
- constructor(props: QRScannerErrorBoundaryProps) {
- super(props);
- this.state = { hasError: false };
- }
+export type ClaimFlowMode = 'guided' | 'fast';
- static getDerivedStateFromError(): QRScannerErrorBoundaryState {
- return { hasError: true };
- }
+// ============================================================
+// TYPES
+// ============================================================
- componentDidCatch(error: Error) {
- if (process.env.NODE_ENV === 'development') {
- logger.warn('QR Scanner error caught by boundary:', error);
- }
+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';
- if (this.props.onError) {
- this.props.onError();
- }
- }
+export interface ClaimedDeviceInfo {
+ deviceId: string;
+ deviceName: string;
+ cohortId: string;
+}
- render(): ReactNode {
- if (this.state.hasError) {
- return null;
- }
+export interface ClaimDeviceModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess?: (deviceInfo: ClaimedDeviceInfo & { isCohortImport?: boolean }) => void;
+ initialStep?: FlowStep;
+ mode?: ClaimFlowMode;
+}
- return this.props.children;
- }
+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'),
+ 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'),
});
-type ClaimDeviceFormData = z.infer;
+export type ClaimDeviceFormData = z.infer;
-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';
+// ============================================================
+// SHARED COMPONENTS
+// ============================================================
-export interface ClaimedDeviceInfo {
- deviceId: string;
- deviceName: string;
- cohortId: string;
-}
+export const ErrorAlert = ({ message }: { message: string }) => (
+
+);
+
+const LoadingSpinner = ({
+ title,
+ subtitle,
+}: {
+ title: string;
+ subtitle?: string;
+}) => (
+
+
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+);
-export interface ClaimDeviceModalProps {
- isOpen: boolean;
- onClose: () => void;
- onSuccess?: (deviceInfo: ClaimedDeviceInfo) => void;
- initialStep?: FlowStep;
-}
+// ============================================================
+// MAIN COMPONENT
+// ============================================================
const ClaimDeviceModal: React.FC = ({
- isOpen,
- onClose,
- onSuccess,
- initialStep = 'method-select',
+ isOpen,
+ onClose,
+ onSuccess,
+ initialStep = 'method-select',
+ mode = 'fast',
}) => {
- const router = useRouter();
- const user = useAppSelector(state => state.user.userDetails);
+ const isGuidedMode = mode === 'guided';
- const { isPersonalContext, isExternalOrg, activeGroup, userScope } = useUserContext();
+ const router = useRouter();
+ const queryClient = useQueryClient();
- const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, {
- enabled: !isPersonalContext && !!activeGroup?._id,
- });
+ const { data: session } = useSession();
+ const user = useAppSelector(state => state.user.userDetails);
- const defaultCohort = groupCohortIds?.[0] || null;
+ const { isPersonalContext, isExternalOrg, activeGroup, userScope } =
+ useUserContext();
- const { mutate: claimDevice, isPending, isSuccess, data: claimData, error: claimError } = useClaimDevice();
+ const userId = (session?.user as { id?: string })?.id || user?._id;
- const { mutate: bulkClaimDevices, isPending: isBulkPending, isSuccess: isBulkSuccess, data: bulkClaimData, error: bulkClaimError } = useBulkClaimDevices();
+ // ==========================================================
+ // Cohorts
+ // ==========================================================
- const [step, setStep] = useState(initialStep);
- const [error, setError] = useState(null);
+ const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, {
+ enabled: !isGuidedMode && !isPersonalContext && !!activeGroup?._id,
+ });
- const [bulkDevices, setBulkDevices] = useState>([]);
- const [pendingSingleClaim, setPendingSingleClaim] = useState<{ deviceId: string; claimToken: string } | null>(null);
- const [cohortIdInput, setCohortIdInput] = useState('');
- const [previousStep, setPreviousStep] = useState('method-select');
- const [isImportingCohort, setIsImportingCohort] = useState(false);
- const [isCohortAssignmentSuccess, setIsCohortAssignmentSuccess] = useState(false);
- const [verifiedCohort, setVerifiedCohort] = useState<{ id: string; name: string } | null>(null);
+ const defaultCohort = groupCohortIds?.[0] || null;
- const { mutateAsync: verifyCohort } = useVerifyCohort();
+ // ==========================================================
+ // Mutations
+ // ==========================================================
- const { mutate: assignCohortsToGroup } = useAssignCohortsToGroup();
- const { mutate: assignCohortsToUser } = useAssignCohortsToUser();
+ const {
+ mutate: claimDevice,
+ isPending,
+ isSuccess,
+ data: claimData,
+ error: claimError,
+ } = useClaimDevice();
- const formMethods = useForm({
- resolver: zodResolver(claimDeviceSchema),
- defaultValues: {
- device_id: '',
- claim_token: '',
- },
+ 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]);
- const resetState = useCallback(() => {
- formMethods.reset();
- setStep('method-select');
- setError(null);
- setBulkDevices([]);
- setPendingSingleClaim(null);
- setCohortIdInput('');
- setPreviousStep('method-select');
- setIsImportingCohort(false);
- setIsCohortAssignmentSuccess(false);
- setVerifiedCohort(null);
- }, [formMethods]);
-
- const handleClose = useCallback(() => {
- resetState();
- onClose();
- }, [resetState, onClose]);
-
- useEffect(() => {
- if (isSuccess && claimData) {
- setStep('success');
-
- if (onSuccess && claimData.device) {
- onSuccess({
- deviceId: claimData.device.name,
- deviceName: claimData.device.long_name || claimData.device.name,
- cohortId: '',
- });
- }
- }
- }, [isSuccess, claimData, onSuccess]);
-
- useEffect(() => {
- if (claimError) {
- setError(claimError.message || 'Failed to claim device. Please try again.');
- setStep('manual-input');
- }
- }, [claimError]);
-
- useEffect(() => {
- if (isPending && step !== 'claiming') {
- setStep('claiming');
- }
- }, [isPending, step]);
-
- // Bulk claim effects
- useEffect(() => {
- if (isBulkSuccess && bulkClaimData) {
- setStep('bulk-results');
- }
- }, [isBulkSuccess, bulkClaimData]);
-
- useEffect(() => {
- if (bulkClaimError) {
- setError(bulkClaimError.message || 'Failed to claim devices. Please try again.');
- setStep('bulk-input');
- }
- }, [bulkClaimError]);
-
- useEffect(() => {
- if (isBulkPending && step !== 'bulk-claiming') {
- setStep('bulk-claiming');
- }
- }, [isBulkPending, step]);
-
- useEffect(() => {
- if (isOpen) {
- setStep(initialStep);
- setError(null);
- formMethods.reset();
- }
- // We only want to reset when the modal opens.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isOpen]);
-
- const handleClaimDevice = (deviceId: string, claimToken: string) => {
- if (!user?._id) {
- setError('User session not available. Please try again.');
- return;
- }
-
- if (!isPersonalContext && !defaultCohort) {
- setError('No cohorts found. Please create a cohort first.');
- return;
- }
-
- setError(null);
- claimDevice({
- device_name: deviceId,
- user_id: user._id,
- claim_token: claimToken,
- ...(defaultCohort && { cohort_id: defaultCohort }),
- });
- };
+ // Single claim: success
+ useEffect(() => {
+ if (!isSuccess || !claimData) return;
- const onManualSubmit = (data: ClaimDeviceFormData) => {
- setPendingSingleClaim({ deviceId: data.device_id, claimToken: data.claim_token });
- setPreviousStep('manual-input');
- setStep('confirmation');
- };
+ if (isPersonalContext) {
+ invalidatePersonalCaches();
+ } else {
+ invalidateGroupCaches();
+ }
- const handleConfirmSingleClaim = () => {
- if (isPending) return;
- if (pendingSingleClaim) {
- handleClaimDevice(pendingSingleClaim.deviceId, pendingSingleClaim.claimToken);
- }
- };
+ setStep('success');
- const handleBulkSubmit = () => {
- if (!user?._id) {
- setError('User session not available. Please try again.');
- return;
- }
+ 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]);
- const validDevices = bulkDevices.filter(d => d.device_name.trim() && d.claim_token.trim());
+ // 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]);
- if (validDevices.length === 0) {
- setError('Please add at least one device with both name and token.');
- return;
- }
+ // Bulk claim: loading
+ useEffect(() => {
+ if (isBulkPending && step !== 'bulk-claiming') {
+ setStep('bulk-claiming');
+ }
+ }, [isBulkPending, step]);
- if (!isPersonalContext && !defaultCohort) {
- setError('No cohorts found. Please create a cohort first.');
- return;
- }
+ // ==========================================================
+ // Single Claim Handlers
+ // ==========================================================
- setError(null);
- setStep('bulk-confirmation');
- };
+ const handleClaimDevice = (deviceId: string, claimToken: string) => {
+ if (!userId) {
+ setError('User session not available. Please try again.');
+ return;
+ }
- const handleConfirmBulkClaim = () => {
- if (!user?._id) return;
+ // GUIDED MODE — claim only, no cohort assignment
+ if (isGuidedMode) {
+ setError(null);
+ claimDevice({
+ device_name: deviceId,
+ user_id: userId,
+ claim_token: claimToken,
+ });
+ return;
+ }
- const validDevices = bulkDevices.filter(d => d.device_name.trim() && d.claim_token.trim());
+ // FAST MODE — claim + optional auto-cohort assignment
+ if (!isPersonalContext && !defaultCohort) {
+ setError('No cohorts found. Please create a cohort first.');
+ return;
+ }
- bulkClaimDevices({
- user_id: user._id,
- devices: validDevices,
- ...(defaultCohort && { cohort_id: defaultCohort }),
- });
- };
+ setError(null);
+ claimDevice({
+ device_name: deviceId,
+ user_id: userId,
+ claim_token: claimToken,
+ ...(defaultCohort && { cohort_id: defaultCohort }),
+ });
+ };
- const handleAddDevice = () => {
- setBulkDevices([...bulkDevices, { device_name: '', claim_token: '' }]);
- };
+ 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 handleRemoveDevice = (index: number) => {
- setBulkDevices(bulkDevices.filter((_, i) => i !== index));
- };
+ const handleQRScan = (result: string) => {
+ const parsed = parseQRCode(result);
- const handleDeviceChange = (index: number, field: 'device_name' | 'claim_token', value: string) => {
- setBulkDevices(prev =>
- prev.map((device, i) =>
- i === index ? { ...device, [field]: value } : device
- )
- );
- };
+ if (!parsed) {
+ setError('Invalid QR code format. Please try manual entry.');
+ setStep('manual-input');
+ return;
+ }
- const handleFileImport = (devices: Array<{ device_name: string; claim_token: string }>) => {
- setBulkDevices(devices);
- };
+ 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 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 (parsed.device_id && parsed.token) {
- return { deviceId: parsed.device_id, claimToken: parsed.token };
- }
- } catch {
- // Not JSON
- }
+ const valid = bulkDevices.filter(
+ d => d.device_name.trim() && d.claim_token.trim(),
+ );
- return null;
- };
+ if (valid.length === 0) {
+ setError('Please add at least one device with both name and token.');
+ return;
+ }
- const handleQRScan = async (result: string) => {
- const parsed = parseQRCode(result);
-
- if (parsed) {
- setPendingSingleClaim({ deviceId: parsed.deviceId, claimToken: parsed.claimToken });
- setPreviousStep('qr-scan');
- setStep('confirmation');
- } else {
- setError('Invalid QR code format. Please try manual entry.');
- setStep('manual-input');
- }
- };
+ setError(null);
+ setStep('bulk-confirmation');
+ };
- const handleConfirmCohortImport = () => {
- if (!verifiedCohort) {
- setError('Session expired or cohort details are missing. Please verify the cohort again.');
- setStep('cohort-import');
- return;
- }
-
- if (isExternalOrg && activeGroup?._id) {
- setStep('assigning-cohort');
- assignCohortsToGroup(
- { groupId: activeGroup._id, cohortIds: [verifiedCohort.id] },
- {
- onSuccess: () => {
- setTimeout(() => {
- setIsCohortAssignmentSuccess(true);
- setStep('success');
- }, 3000);
- },
- onError: (err) => {
- setError(getApiErrorMessage(err));
- setStep('cohort-import');
- },
- }
- );
- return;
- }
-
- if (isPersonalContext) {
- if (!user?._id) {
- setError('User session not available. Please try again.');
- setStep('cohort-import');
- return;
+ 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,
+ });
}
- setStep('assigning-cohort');
- assignCohortsToUser(
- { userId: user._id, cohortIds: [verifiedCohort.id] },
- {
- onSuccess: () => {
- setTimeout(() => {
- setIsCohortAssignmentSuccess(true);
- setStep('success');
- }, 3000);
- },
- onError: (err) => {
- setError(getApiErrorMessage(err));
- setStep('cohort-import');
- },
- }
- );
- return;
- }
-
- // Fallback if context is indeterminate
- setError('Unable to determine assignment context. Please try again.');
- setStep('cohort-import');
- };
+ setTimeout(() => {
+ setIsCohortAssignmentSuccess(true);
+ setStep('success');
+ }, 1500);
+ },
+ onError: err => {
+ setError(getApiErrorMessage(err));
+ setStep('cohort-import');
+ },
+ },
+ );
+ return;
+ }
- const handleVerifyCohort = async () => {
- const input = cohortIdInput.trim();
- if (!input) {
- setError('Please enter a valid Cohort ID');
- return;
- }
-
- // Validate 24-char alphanumeric string
- if (!/^[a-zA-Z0-9]{24}$/.test(input)) {
- setError('Cohort ID must be a 24-character alphanumeric code.');
- return;
- }
-
- setError(null);
- setIsImportingCohort(true);
-
- try {
- const result = await verifyCohort(input);
-
- if (result.success) {
- const cohortName =
- (result as { data?: { name?: string } }).data?.name ||
- result?.cohort?.name ||
- '';
- if (cohortName?.toLowerCase() === 'airqo') {
- setError('This cohort is not available.');
- setIsImportingCohort(false);
- return;
- }
-
- if (isExternalOrg) {
- if (!activeGroup?._id) {
- setError('No organization is selected. Please try again.');
- return;
- }
- setVerifiedCohort({ id: input, name: cohortName || input });
- setStep('cohort-confirm');
- return;
- }
-
- if (isPersonalContext) {
- if (!user?._id) {
- setError('User session not available. Please try again.');
- return;
- }
- setVerifiedCohort({ id: input, name: cohortName || input });
- setStep('cohort-confirm');
- return;
- }
-
- try {
- const cohortDetails = await cohortsApi.getCohortDetailsApi(input);
- const cohort = Array.isArray(cohortDetails?.cohorts) ? cohortDetails.cohorts[0] : null;
-
- if (cohort && Array.isArray(cohort.devices) && cohort.devices.length > 0) {
- const devices = cohort.devices.map((d: unknown) => {
- const device = d as Device;
- return {
- device_name: device.name || '',
- claim_token: ''
- };
- });
- setBulkDevices(devices);
- setStep('bulk-input');
- } else {
- setError('Cohort found but it has no devices assigned.');
- }
- } catch (detailsErr: unknown) {
- const message = detailsErr instanceof Error ? detailsErr.message : 'Failed to fetch cohort devices';
- setError(message);
- }
- } else {
- setError(result.message || 'Invalid Cohort ID');
+ if (isPersonalContext && userId) {
+ assignCohortsToUser(
+ { userId, cohortIds: [verifiedCohort.id] },
+ {
+ onSuccess: () => {
+ invalidatePersonalCaches();
+ if (onSuccess) {
+ onSuccess({
+ deviceId: '',
+ deviceName: '',
+ cohortId: verifiedCohort.id,
+ isCohortImport: true,
+ });
}
- } catch (err: unknown) {
- const message = err instanceof Error ? err.message : 'Failed to verify Cohort ID';
- setError(message);
- } finally {
- setIsImportingCohort(false);
- }
+ 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 getDialogConfig = () => {
- const baseConfig = {
- title: 'Claim AirQo Device',
- showFooter: false,
- showCloseButton: true,
- preventBackdropClose: false,
- primaryAction: undefined,
- secondaryAction: 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'),
};
- switch (step) {
- case 'method-select':
- return { ...baseConfig, showFooter: false };
- case 'qr-scan':
- return {
- ...baseConfig,
- title: 'Scan QR Code',
- showFooter: true,
- secondaryAction: { label: 'Back', onClick: () => setStep('method-select'), variant: 'outline' as const },
- };
- case 'cohort-import':
- return {
- ...baseConfig,
- title: 'Import from Cohort',
- showFooter: true,
- primaryAction: { label: isImportingCohort ? 'Verifying...' : 'Import', onClick: handleVerifyCohort, disabled: isImportingCohort },
- secondaryAction: { label: 'Back', onClick: () => setStep('method-select'), variant: 'outline' as const },
- };
- case 'cohort-confirm':
- return {
- ...baseConfig,
- title: 'Confirm Cohort Import',
- showFooter: true,
- primaryAction: { label: 'Confirm & Import', onClick: handleConfirmCohortImport },
- secondaryAction: { label: 'Cancel', onClick: () => setStep('cohort-import'), variant: 'outline' as const },
- };
- case 'assigning-cohort':
- return { ...baseConfig, title: 'Assigning Cohort...', showCloseButton: false, preventBackdropClose: true, showFooter: false };
- case 'manual-input':
- return {
- ...baseConfig,
- showFooter: true,
- primaryAction: { label: isPending ? 'Claiming...' : 'Claim Device', onClick: formMethods.handleSubmit(onManualSubmit), disabled: isPending },
- secondaryAction: { label: 'Back', onClick: () => setStep('qr-scan'), variant: 'outline' as const },
- };
- case 'bulk-input':
- return {
- ...baseConfig,
- title: 'Add Multiple Devices',
- showFooter: true,
- primaryAction: { label: 'Review & Claim', onClick: handleBulkSubmit },
- secondaryAction: { label: 'Back', onClick: () => setStep('method-select'), variant: 'outline' as const },
- };
- case 'confirmation':
- return {
- ...baseConfig,
- title: 'Confirm Claim',
- showFooter: true,
- primaryAction: { label: isPending ? 'Claiming...' : 'Confirm & Claim', onClick: handleConfirmSingleClaim, disabled: isPending },
- secondaryAction: { label: 'Cancel', onClick: () => setStep(previousStep), variant: 'outline' as const },
- };
- case 'bulk-confirmation':
- return {
- ...baseConfig,
- 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 'claiming':
- return { ...baseConfig, title: 'Claiming Device...', showCloseButton: false, preventBackdropClose: true, showFooter: false };
- case 'bulk-claiming':
- return { ...baseConfig, title: 'Claiming Devices...', showCloseButton: false, preventBackdropClose: true, showFooter: false };
- case 'success':
- return {
- ...baseConfig,
- title: 'Success!',
- showFooter: true,
- primaryAction: {
- label: 'Go to Devices',
- onClick: () => {
- handleClose();
- const redirectPath = userScope === 'personal' ? '/devices/my-devices' : '/devices/overview';
- router.push(redirectPath);
- }
- }
- };
- case 'bulk-results': {
- const hasSuccessfulClaims = (bulkClaimData?.data?.successful_claims?.length ?? 0) > 0;
- return {
- ...baseConfig,
- title: 'Bulk Claim Results',
- showFooter: true,
- primaryAction: {
- label: hasSuccessfulClaims ? 'Go to Devices' : 'Close',
- onClick: () => {
- handleClose();
- if (hasSuccessfulClaims) {
- const redirectPath = userScope === 'personal' ? '/devices/my-devices' : '/devices/overview';
- router.push(redirectPath);
- }
- }
- }
- };
- }
- default:
- return baseConfig;
- }
- };
+ 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',
+ ),
+ };
- const dialogConfig = getDialogConfig();
+ 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,
+ },
+ };
- return (
- {
+ 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' && (
+
-
- {step === 'method-select' && (
-
-
Choose how you would like to add your device(s).
-
- {/* Card A: Add Single Device */}
-
{
- setStep('qr-scan');
- }}
- className="flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors text-left w-full group"
- >
-
-
-
-
-
Add Single Device
-
- Scan a QR code or manually enter a Device ID.
-
-
-
-
- {/* Card B: Add Multiple Devices */}
-
{
- setStep('bulk-input');
- }}
- className="flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors text-left w-full group"
- >
-
-
-
-
-
Add Multiple Devices
-
- Upload a CSV file or enter a list of IDs for bulk setup.
-
-
-
-
- {/* Card C: Import from Cohort */}
-
{
- setStep('cohort-import');
- }}
- className="flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-violet-500 dark:hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors text-left w-full group"
- >
-
-
-
-
-
Import from Cohort
-
- Enter a Cohort ID to prefill devices.
-
-
-
-
-
- )}
-
-
-
- {step === 'qr-scan' && isOpen && (
-
{
- setStep('manual-input');
- setError('QR scanner encountered an issue. Please enter details manually.');
- }}
- >
-
- {/* Cohort confirmation message */}
- {!isPersonalContext && defaultCohort && (
-
-
- Device will be added to:
- {isExternalOrg && activeGroup && ` ${activeGroup.grp_title}`}
- {isPersonalContext && ` as your personal devices`}
-
-
- You can change ownership or share devices later.
-
-
- )}
-
-
setStep('manual-input')} className="w-full text-sm text-blue-600 dark:text-blue-400 hover:underline">
- Having trouble? Enter details manually
-
-
-
- )}
-
- {step === 'cohort-import' && (
-
-
- Enter the Cohort ID to automatically load its devices.
-
-
- {
- setCohortIdInput(e.target.value);
- setError(null);
- }}
- error={error || undefined}
- />
-
- {isImportingCohort && (
-
-
- Verifying Cohort ID...
-
- )}
-
- )}
-
- {step === 'cohort-confirm' && verifiedCohort && (
-
-
-
- Cohort Name: {verifiedCohort.name}
-
-
- This will add the devices from this cohort to your {isExternalOrg ? 'organization' : 'personal'} assets.
-
-
-
- Continue to import this cohort?
-
-
- )}
-
- {step === 'manual-input' && (
-
- )}
-
- {/* Confirmation Step */}
- {step === 'confirmation' && pendingSingleClaim && (
-
-
-
-
- Confirm Device Claim
-
-
- Are you sure you want to claim this device?
-
-
-
- Warning: If this device is currently{' '}
-
-
- deployed
-
-
- Deployment triggers data transmission for a device
-
-
- , it will be automatically{' '}
-
-
- recalled
-
-
- Recalling removes a device from a Site (e.g., for repair) without deleting it from your inventory.
-
-
- .
-
-
-
-
- )}
-
- {/* Bulk Confirmation Step */}
- {step === 'bulk-confirmation' && (
-
-
-
-
- Confirm Bulk Claim
-
-
- You are about to claim {bulkDevices.filter(d => d.device_name.trim() && d.claim_token.trim()).length} devices.
-
-
-
- Warning: Any devices currently{' '}
-
-
- deployed
-
-
- Deployment triggers data transmission for a device
-
-
- {' '}will be automatically{' '}
-
-
- recalled
-
-
- Recalling removes a device from a Site (e.g., for repair) without deleting it from your inventory.
-
-
- {' '}and added to your inventory.
-
-
-
-
- )}
-
- {step === 'claiming' && (
-
-
-
-
Claiming Your Device
-
Please wait while we set up your device...
-
-
- )}
-
- {step === 'assigning-cohort' && (
-
-
-
-
Assigning Cohort
-
Adding cohort devices to your {isExternalOrg ? 'organization' : 'personal'} assets...{' '}Please wait...
-
-
- )}
-
- {step === 'success' && (claimData?.device || isCohortAssignmentSuccess) && (
-
-
-
-
-
-
- {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
-
-
-
- )}
-
- )}
-
- {/* Bulk Input Step */}
- {step === 'bulk-input' && (
-
- {bulkDevices.length === 0 ? (
-
-
-
-
-
-
-
-
-
-
- Enter device details manually
-
-
- ) : (
- <>
- {/* Cohort confirmation message */}
- {!isPersonalContext && defaultCohort && (
-
-
- Devices will be added to:
- {isExternalOrg && activeGroup && ` ${activeGroup.grp_title}`}
- {isPersonalContext && ` as your personal devices`}
-
-
- You can change ownership or share devices later.
-
-
- )}
-
-
- Review your devices before claiming.
-
-
setBulkDevices([])}
- className="text-xs text-red-600 hover:text-red-700 dark:text-red-400"
- >
- Clear All
-
-
-
- {/* Device Entry Rows */}
-
- {bulkDevices.map((device, index) => (
-
- handleDeviceChange(index, 'device_name', value)
- }
- onClaimTokenChange={(value) =>
- handleDeviceChange(index, 'claim_token', value)
- }
- onRemove={() => handleRemoveDevice(index)}
- showRemove={true}
- />
- ))}
-
-
- {/* Add Device Button */}
-
-
- Add Row
-
-
- setBulkDevices([...bulkDevices, ...devices])} />
-
-
-
- {error && (
-
- )}
-
-
-
- {bulkDevices.filter((d) => d.device_name.trim() && d.claim_token.trim()).length}
- {' '}
- device(s) ready to claim
-
- >
- )}
-
- )}
-
- {/* Bulk Claiming Loading State */}
- {step === 'bulk-claiming' && (
-
-
-
-
- Claiming Devices
-
-
- Please wait while we process your devices...
-
-
-
- )}
-
- {/* Bulk Results Step */}
- {step === 'bulk-results' && bulkClaimData?.data && (
-
- )}
-
-
- );
+ isPersonalContext={isPersonalContext}
+ isExternalOrg={isExternalOrg}
+ defaultCohort={defaultCohort}
+ activeGroupTitle={activeGroup?.grp_title}
+ onScan={handleQRScan}
+ onManualEntry={() => {
+ 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;
-
+export default ClaimDeviceModal;
\ No newline at end of file
diff --git a/src/vertex/components/features/claim/BulkClaimResults.tsx b/src/vertex/components/features/claim/steps/BulkClaimResults.tsx
similarity index 100%
rename from src/vertex/components/features/claim/BulkClaimResults.tsx
rename to src/vertex/components/features/claim/steps/BulkClaimResults.tsx
diff --git a/src/vertex/components/features/claim/steps/BulkInputStep.tsx b/src/vertex/components/features/claim/steps/BulkInputStep.tsx
new file mode 100644
index 0000000000..5bac6b7cb2
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/CohortAssignmentBanner.tsx b/src/vertex/components/features/claim/steps/CohortAssignmentBanner.tsx
new file mode 100644
index 0000000000..0c024fbd09
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/CohortImportStep.tsx b/src/vertex/components/features/claim/steps/CohortImportStep.tsx
new file mode 100644
index 0000000000..37dec07cc5
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/ConfirmationSteps.tsx b/src/vertex/components/features/claim/steps/ConfirmationSteps.tsx
new file mode 100644
index 0000000000..3f89e966b8
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/ManualInputStep.tsx b/src/vertex/components/features/claim/steps/ManualInputStep.tsx
new file mode 100644
index 0000000000..4b898f18b3
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/MethodSelectStep.tsx b/src/vertex/components/features/claim/steps/MethodSelectStep.tsx
new file mode 100644
index 0000000000..c6bf594840
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/QRScanStep.tsx b/src/vertex/components/features/claim/steps/QRScanStep.tsx
new file mode 100644
index 0000000000..700f0eae0a
--- /dev/null
+++ b/src/vertex/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/components/features/claim/steps/SuccessStep.tsx b/src/vertex/components/features/claim/steps/SuccessStep.tsx
new file mode 100644
index 0000000000..01d027b901
--- /dev/null
+++ b/src/vertex/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/components/features/claim/utils.ts b/src/vertex/components/features/claim/utils.ts
new file mode 100644
index 0000000000..2c6ace582d
--- /dev/null
+++ b/src/vertex/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/components/features/cohorts/assign-cohort-devices.tsx b/src/vertex/components/features/cohorts/assign-cohort-devices.tsx
index e8c30a209c..e8b825d68e 100644
--- a/src/vertex/components/features/cohorts/assign-cohort-devices.tsx
+++ b/src/vertex/components/features/cohorts/assign-cohort-devices.tsx
@@ -32,6 +32,7 @@ interface AssignCohortDevicesDialogProps {
selectedDevices?: Device[];
onSuccess?: () => void;
cohortId?: string;
+ title?: string;
}
const formSchema = z.object({
@@ -49,6 +50,7 @@ export function AssignCohortDevicesDialog({
selectedDevices,
onSuccess,
cohortId,
+ title = "Add devices to cohort",
}: AssignCohortDevicesDialogProps) {
const { isExternalOrg, activeGroup } = useUserContext();
const { showBanner } = useBanner();
@@ -160,7 +162,7 @@ export function AssignCohortDevicesDialog({
setDeviceSearch("");
setDebouncedDeviceSearch("");
}
- }, [open, selectedDevices, form, cohortId]);
+ }, [open, form, cohortId, selectedDevices]);
const handleCreateCohortSuccess = () => {
setCreateCohortModalOpen(false);
@@ -214,7 +216,7 @@ export function AssignCohortDevicesDialog({
handleOpenChange(false)}
- title="Add devices to cohort"
+ title={title}
subtitle={`${form.watch("devices")?.length || 0} device(s) selected`}
size="lg"
maxHeight="max-h-[70vh]"
diff --git a/src/vertex/components/features/cohorts/cohort-organizations-card.tsx b/src/vertex/components/features/cohorts/cohort-organizations-card.tsx
new file mode 100644
index 0000000000..4275b03187
--- /dev/null
+++ b/src/vertex/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/components/features/cohorts/cohorts-empty-state.tsx b/src/vertex/components/features/cohorts/cohorts-empty-state.tsx
index e5aa312930..8f2dd89c45 100644
--- a/src/vertex/components/features/cohorts/cohorts-empty-state.tsx
+++ b/src/vertex/components/features/cohorts/cohorts-empty-state.tsx
@@ -30,7 +30,7 @@ const CohortsEmptyState = () => {
setIsClaimModalOpen(true)} Icon={Plus}>
- Claim AirQo Device
+ Add AirQo Device
diff --git a/src/vertex/components/features/cohorts/create-cohort.tsx b/src/vertex/components/features/cohorts/create-cohort.tsx
index 3595c046ea..e159693d97 100644
--- a/src/vertex/components/features/cohorts/create-cohort.tsx
+++ b/src/vertex/components/features/cohorts/create-cohort.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
+import { useRouter, usePathname } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
@@ -13,6 +13,8 @@ import ReusableInputField from "@/components/shared/inputfield/ReusableInputFiel
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,
@@ -37,6 +39,8 @@ interface CreateCohortDialogProps {
onError?: (error: unknown) => void;
andNavigate?: boolean;
preselectedDevices?: PreselectedDevice[];
+ hideDeviceSelection?: boolean;
+ preselectedNetwork?: string;
}
const formSchema = z.object({
@@ -47,12 +51,8 @@ const formSchema = z.object({
network: z.string().min(1, {
message: "Please select a Sensor Manufacturer.",
}),
- devices: z.array(z.string()).min(1, {
- message: "Please select at least one device.",
- }),
- cohort_tags: z.array(z.string()).min(1, {
- message: "Please select at least one tag.",
- }),
+ 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) {
@@ -75,8 +75,15 @@ export function CreateCohortDialog({
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),
@@ -85,7 +92,7 @@ export function CreateCohortDialog({
city: "",
projectName: "",
funder: "",
- network: "",
+ network: preselectedNetwork || "",
devices: preselectedDevices.map((d) => d.value),
cohort_tags: [],
},
@@ -145,7 +152,7 @@ export function CreateCohortDialog({
city: "",
projectName: "",
funder: "",
- network: "",
+ network: preselectedNetwork || "",
devices: preselectedDevices.map((d) => d.value),
cohort_tags: [],
});
@@ -155,11 +162,20 @@ export function CreateCohortDialog({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [open, preselectedDevices]);
+ }, [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 (response?.cohort) {
+ if (isEmbeddedMode) {
+ onSuccess?.(response);
+ onOpenChange(false);
+ } else if (response?.cohort) {
setCreatedCohort(response.cohort);
setStep("success");
onSuccess?.(response);
@@ -222,7 +238,7 @@ export function CreateCohortDialog({
}
// Merge with existing selections
- const currentDevices = form.getValues('devices');
+ const currentDevices = form.getValues('devices') || [];
const uniqueDevices = Array.from(new Set([...currentDevices, ...matchedIds]));
form.setValue('devices', uniqueDevices);
@@ -250,7 +266,28 @@ export function CreateCohortDialog({
const derivedName = isOrganizational
? buildCohortName(values.city || "", values.projectName || "", values.funder)
: (values.name || "").trim();
- createCohort({ name: derivedName, network: values.network, deviceIds: values.devices, cohort_tags: values.cohort_tags });
+
+ 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 = () => {
@@ -267,9 +304,9 @@ export function CreateCohortDialog({
case 'success':
return {
title: 'Success!',
- primaryLabel: 'Go to Cohort Details',
+ primaryLabel: andNavigate ? 'Go to Cohort Details' : 'Close',
primaryAction: () => {
- if (createdCohort?._id) {
+ if (andNavigate && createdCohort?._id) {
router.push(`/admin/cohorts/${createdCohort._id}`);
} else {
onOpenChange(false);
@@ -307,7 +344,7 @@ export function CreateCohortDialog({
primaryAction={{
label: config.primaryLabel,
onClick: config.primaryAction,
- disabled: isPending || (step === 'form' && !selectedNetwork),
+ disabled: isPending,
}}
secondaryAction={{
label: config.secondaryLabel,
@@ -320,23 +357,25 @@ export function CreateCohortDialog({
{step === 'form' && (
)}
@@ -532,7 +577,7 @@ export function CreateCohortDialog({
Cohort Created Successfully!
- Cohort {createdCohort.name} has been created with {formValues.devices.length} devices.
+ Cohort {createdCohort.name} has been created{hideDeviceSelection ? '.' : ` with ${formValues.devices?.length || 0} devices.`}
diff --git a/src/vertex/components/features/cohorts/organization-setup-dialog.tsx b/src/vertex/components/features/cohorts/organization-setup-dialog.tsx
deleted file mode 100644
index 2480b66261..0000000000
--- a/src/vertex/components/features/cohorts/organization-setup-dialog.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import React, { useState } from 'react';
-import ReusableDialog from '@/components/shared/dialog/ReusableDialog';
-import { useVerifyCohort } from '@/core/hooks/useCohorts';
-import ReusableInputField from '@/components/shared/inputfield/ReusableInputField';
-import { Loader2, Plus, Database, CheckCircle2 } from 'lucide-react';
-
-type SetupView = 'INTRO' | 'CHOICE' | 'LINK' | 'CONFIRM_LINK';
-
-interface OrganizationSetupDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirmCreate: () => void;
- onConfirmLink: (cohortId: string) => void;
- isProcessing: boolean;
- preventDismiss?: boolean;
-}
-
-export const OrganizationSetupDialog: React.FC
= ({
- open,
- onOpenChange,
- onConfirmCreate,
- onConfirmLink,
- isProcessing,
- preventDismiss = false,
-}) => {
- const [view, setView] = useState('INTRO');
- const [cohortId, setCohortId] = useState('');
- const [verifiedCohortId, setVerifiedCohortId] = useState(null);
- const [error, setError] = useState(null);
-
- const { mutateAsync: verifyCohort, isPending: isVerifying } = useVerifyCohort();
-
- const handleClose = () => {
- onOpenChange(false);
- setTimeout(() => {
- setView('INTRO');
- setCohortId('');
- setVerifiedCohortId(null);
- setError(null);
- }, 300);
- };
-
- const handleVerify = async () => {
- setError(null);
- if (!cohortId.trim()) {
- setError('Please enter a valid Cohort ID');
- return;
- }
-
- try {
- const result = await verifyCohort(cohortId.trim());
- if (result?.success) {
- setVerifiedCohortId(cohortId.trim());
- setView('CONFIRM_LINK');
- } else {
- setError('Cohort not found. Please check the ID and try again.');
- }
- } catch {
- setError('Failed to verify cohort. User might not have access or ID is invalid.');
- }
- };
-
- const renderIntroView = () => (
-
-
- Your organization needs at least one cohort to manage devices. Cohorts help you organize and manage groups of devices effectively.
-
-
- Click "Complete Setup" to create a default cohort or link an existing one.
-
-
- );
-
- const renderChoiceView = () => (
-
-
- {/* Card A: Create New Cohort */}
-
-
-
-
Create New Cohort
-
- Create a default cohort for your organization.
-
-
-
-
- {/* Card B: Link Existing Cohort */}
-
setView('LINK')}
- className="flex items-center p-4 border-2 border-gray-200 dark:border-gray-700 rounded-lg hover:border-violet-500 dark:hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 transition-colors text-left w-full group"
- >
-
-
-
-
-
I have a Cohort ID
-
- Link an existing cohort using its unique ID.
-
-
-
-
-
- );
-
- const renderLinkView = () => (
-
-
-
{
- setCohortId(e.target.value);
- setError(null);
- }}
- error={error || undefined}
- disabled={isVerifying}
- />
- {isVerifying && (
-
-
- Verifying Cohort ID...
-
- )}
-
-
- );
-
- const renderConfirmLinkView = () => (
-
-
-
-
-
-
- Confirm Cohort Link
-
-
- You are about to link Cohort ID {verifiedCohortId} to your organization.
-
-
-
- );
-
- const getDialogContent = () => {
- const baseConfig = {
- title: 'Organization Setup',
- subtitle: undefined as string | undefined,
- showFooter: false,
- showCloseButton: !preventDismiss,
- preventBackdropClose: preventDismiss,
- primaryAction: undefined,
- secondaryAction: undefined,
- };
-
- switch (view) {
- case 'INTRO':
- return {
- ...baseConfig,
- title: 'Complete Organization Setup',
- showFooter: true,
- content: renderIntroView(),
- primaryAction: {
- label: 'Complete Setup',
- onClick: () => setView('CHOICE'),
- },
- secondaryAction: preventDismiss ? undefined : { label: 'Later', onClick: handleClose, variant: 'outline' as const }
- };
- case 'CHOICE':
- return {
- ...baseConfig,
- subtitle: 'How would you like to set up your organization?',
- content: renderChoiceView(),
- secondaryAction: preventDismiss ? undefined : { label: 'Cancel', onClick: handleClose, variant: 'outline' as const }
- };
- case 'LINK':
- return {
- ...baseConfig,
- title: 'Link Existing Cohort',
- showFooter: true,
- content: renderLinkView(),
- primaryAction: {
- label: isVerifying ? 'Verifying...' : 'Verify ID',
- onClick: handleVerify,
- disabled: !cohortId.trim() || isVerifying
- },
- secondaryAction: {
- label: 'Back',
- onClick: () => {
- setError(null);
- setView('CHOICE');
- },
- variant: 'outline' as const
- }
- };
- case 'CONFIRM_LINK':
- return {
- ...baseConfig,
- title: 'Confirm Link',
- showFooter: true,
- content: renderConfirmLinkView(),
- primaryAction: {
- label: isProcessing ? 'Linking...' : 'Confirm & Link',
- onClick: () => verifiedCohortId && onConfirmLink(verifiedCohortId),
- disabled: isProcessing
- },
- secondaryAction: {
- label: 'Back',
- onClick: () => setView('LINK'),
- variant: 'outline' as const
- }
- };
- default:
- return { ...baseConfig, content: null };
- }
- };
-
- const params = getDialogContent();
-
- return (
-
- {params.content}
-
- );
-};
diff --git a/src/vertex/components/features/cohorts/unassign-cohort-from-group.tsx b/src/vertex/components/features/cohorts/unassign-cohort-from-group.tsx
new file mode 100644
index 0000000000..8d7a3e1cd9
--- /dev/null
+++ b/src/vertex/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/components/features/dashboard/stats-cards.tsx b/src/vertex/components/features/dashboard/stats-cards.tsx
index 22d8eb1e6b..9b10f58ffd 100644
--- a/src/vertex/components/features/dashboard/stats-cards.tsx
+++ b/src/vertex/components/features/dashboard/stats-cards.tsx
@@ -1,7 +1,9 @@
"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,
@@ -10,15 +12,24 @@ import {
} 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 { userScope, userDetails } = useUserContext();
+ 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 personalCohortIds = userDetails?.cohort_ids || [];
const shouldEnable = isPersonalScope ? personalCohortIds.length > 0 : true;
// Use useDeviceCount for both scopes
@@ -29,7 +40,7 @@ export const DashboardStatsCards = () => {
cohortIds: isPersonalScope ? personalCohortIds : undefined,
});
- const isLoading = shouldEnable ? deviceCountQuery.isLoading : false;
+ const isLoading = (isPersonalScope && isLoadingPersonalCohorts) || (shouldEnable && deviceCountQuery.isLoading);
const metrics = useMemo(() => {
const summary = deviceCountQuery.data?.summary;
diff --git a/src/vertex/components/features/devices/deploy-device-component.tsx b/src/vertex/components/features/devices/deploy-device-component.tsx
index 6479bc0513..8b3d822124 100644
--- a/src/vertex/components/features/devices/deploy-device-component.tsx
+++ b/src/vertex/components/features/devices/deploy-device-component.tsx
@@ -31,7 +31,13 @@ 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;
@@ -436,6 +442,7 @@ const DeployDeviceComponent = ({
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 ?? "",
@@ -486,12 +493,6 @@ const DeployDeviceComponent = ({
return availableDevices;
}, [prefilledDevice, availableDevices]);
- // When returning from claim page, refresh device list (only for personal scope)
- React.useEffect(() => {
- if (userScope === 'personal') {
- refetchDevices();
- }
- }, [userScope, refetchDevices]);
const selectedDeviceId = React.useMemo(() => {
if (prefilledDevice?._id) return prefilledDevice._id;
@@ -587,11 +588,7 @@ const DeployDeviceComponent = ({
};
const handleClaimDevice = () => {
- // In modal context, we might want to handle this differently
- if (onClose) {
- onClose();
- }
- window.location.href = '/devices/claim';
+ setIsClaimModalOpen(true);
};
const handleCheckboxChange = (checked: boolean): void => {
@@ -845,6 +842,18 @@ const DeployDeviceComponent = ({
))}
+
+ {
+ setIsClaimModalOpen(false);
+ if (userScope === 'personal') {
+ refetchDevices();
+ } else {
+ queryClient.invalidateQueries({ queryKey: ['devices'] });
+ }
+ }}
+ />
);
};
diff --git a/src/vertex/components/features/devices/device-assignment-modal.tsx b/src/vertex/components/features/devices/device-assignment-modal.tsx
index cd518d3ac3..c5eb0107a8 100644
--- a/src/vertex/components/features/devices/device-assignment-modal.tsx
+++ b/src/vertex/components/features/devices/device-assignment-modal.tsx
@@ -18,7 +18,6 @@ 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 { useRouter } from "next/navigation";
import {
Select,
SelectContent,
@@ -26,6 +25,12 @@ import {
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[];
@@ -42,10 +47,10 @@ const DeviceAssignmentModal: React.FC = ({
onSuccess,
isLoadingDevices,
}) => {
- const router = useRouter();
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();
@@ -78,7 +83,7 @@ const DeviceAssignmentModal: React.FC = ({
};
const handleClaimDevice = () => {
- router.push("/devices/claim");
+ setIsClaimModalOpen(true);
};
const handleSelectChange = (value: string) => {
@@ -158,6 +163,10 @@ const DeviceAssignmentModal: React.FC = ({
+ setIsClaimModalOpen(false)}
+ />
);
};
diff --git a/src/vertex/components/features/devices/device-details-modal.tsx b/src/vertex/components/features/devices/device-details-modal.tsx
index 4954999d63..65b2778560 100644
--- a/src/vertex/components/features/devices/device-details-modal.tsx
+++ b/src/vertex/components/features/devices/device-details-modal.tsx
@@ -1,5 +1,5 @@
import { Switch } from "@/components/ui/switch";
-import { Loader2, Save, RefreshCw } from "lucide-react";
+import { Loader2, Save } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -233,6 +233,15 @@ const DeviceDetailsModal: React.FC = ({ open, device, o
);
};
+ 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 {
@@ -292,24 +301,13 @@ const DeviceDetailsModal: React.FC = ({ open, device, o
- Sync Global
-
-
-
- Save Local
+ Save
>
) : (
diff --git a/src/vertex/components/features/devices/import-device-modal.tsx b/src/vertex/components/features/devices/import-device-modal.tsx
index 3cd833b59a..79490c6968 100644
--- a/src/vertex/components/features/devices/import-device-modal.tsx
+++ b/src/vertex/components/features/devices/import-device-modal.tsx
@@ -1,54 +1,97 @@
"use client";
-import React, { useState } from "react";
+import React, { useState, useEffect, useCallback } from "react";
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 { 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 { useUserContext } from "@/core/hooks/useUserContext";
-import { useGroupCohorts } from "@/core/hooks/useCohorts";
+import { useAssignDevicesToCohort } from "@/core/hooks/useCohorts";
import { useAppSelector } from "@/core/redux/hooks";
import { usePathname } from "next/navigation";
-import logger from "@/lib/logger";
import { useBanner } from "@/context/banner-context";
import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
import { NetworkRequestDialog } from "../networks/network-request-dialog";
-import { MultiSelectCombobox } from "@/components/ui/multi-select";
-import ReusableFileUpload from "@/components/shared/fileupload/ReusableFileUpload";
-import { DEFAULT_DEVICE_TAGS } from "@/core/constants/devices";
-import { Label } from "@/components/ui/label";
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 EXPECTED_FIELDS = [
- { 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 },
-];
-
-
+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 [formData, setFormData] = useState({
+ 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,
@@ -65,82 +108,79 @@ const ImportDeviceModal: React.FC = ({
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 [mappingMode, setMappingMode] = useState(false);
- const [previewMode, setPreviewMode] = useState(false);
const [transformedPreview, setTransformedPreview] = useState[]>([]);
- const { showBanner, hideBanner } = useBanner();
+
+ 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 { userContext, activeGroup } = useUserContext();
const userDetails = useAppSelector((state) => state.user.userDetails);
- const shouldFetchGroupCohorts = userContext === 'external-org' && !!activeGroup?._id;
-
- const { data: groupCohorts } = useGroupCohorts(activeGroup?._id, {
- enabled: shouldFetchGroupCohorts,
- });
-
const pathname = usePathname();
const isAdminPage = pathname?.startsWith('/admin/');
- const validateForm = () => {
- 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";
- }
+ 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]);
- if (!formData.api_code?.trim()) {
- newErrors.api_code = "Device Connection URL is required";
+ 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]);
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- };
-
- const getCohortId = (): string | undefined => {
- if (userContext === 'external-org' && groupCohorts && groupCohorts.length > 0) {
- return groupCohorts[0];
+ const handleInputChange = (field: string, value: string | boolean | string[]) => {
+ setFormData((prev: ImportDeviceFormData) => ({
+ ...prev,
+ [field]: value,
+ }));
+ if (errors[field]) {
+ setErrors((prev) => ({ ...prev, [field]: "" }));
}
-
- return undefined;
- };
-
- const downloadFailedRows = () => {
- if (!bulkResults || !bulkResults.results) return;
- const failedRows = bulkResults.results.filter(r => !r.success);
- if (failedRows.length === 0) return;
-
- const headers = ['serial_number', 'long_name', 'error'];
- const csvContent = [
- headers.join(','),
- ...failedRows.map(r => `"${r.serial_number || ''}","${r.long_name || ''}","${(r.error || '').replace(/"/g, '""')}"`)
- ].join('\n');
-
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
- const link = document.createElement('a');
- const url = URL.createObjectURL(blob);
- link.setAttribute('href', url);
- link.setAttribute('download', 'failed_devices.csv');
- link.style.visibility = 'hidden';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
};
const autoMapFields = (headers: string[]) => {
@@ -149,7 +189,7 @@ const ImportDeviceModal: React.FC = ({
value.toLowerCase().trim().replace(/[^a-z0-9]/g, "");
const normalizedHeaders = headers.map(normalizeHeader);
- EXPECTED_FIELDS.forEach(field => {
+ EXPECTED_FIELDS.forEach((field: { key: string; label: string; required?: boolean }) => {
let matchIdx = -1;
const key = normalizeHeader(field.key);
@@ -178,15 +218,17 @@ const ImportDeviceModal: React.FC = ({
setBulkFile(file);
setErrors({});
if (!file) {
- setMappingMode(false);
setParsedData([]);
setFileHeaders([]);
setFieldMapping({});
- setPreviewMode(false);
setTransformedPreview([]);
+ if (currentStep === 0) {
+ setImportFlow('single');
+ }
return;
}
+ setImportFlow('bulk');
const fileName = file.name.toLowerCase();
if (fileName.endsWith('.csv')) {
@@ -201,7 +243,6 @@ const ImportDeviceModal: React.FC = ({
setFileHeaders(headers);
setParsedData([]);
setFieldMapping({});
- setMappingMode(false);
showBanner({ severity: 'error', message: "The uploaded CSV does not contain any devices.", scoped: true });
return;
}
@@ -209,7 +250,6 @@ const ImportDeviceModal: React.FC = ({
setFileHeaders(headers);
setParsedData(rows);
autoMapFields(headers);
- setMappingMode(true);
},
error: (err: { message: string }) => {
showBanner({ severity: 'error', message: `Failed to parse CSV: ${err.message}`, scoped: true });
@@ -228,7 +268,6 @@ const ImportDeviceModal: React.FC = ({
setFileHeaders(headers);
setParsedData(devices as Record[]);
autoMapFields(headers);
- setMappingMode(true);
} catch {
showBanner({ severity: 'error', message: 'Invalid JSON file format', scoped: true });
}
@@ -237,127 +276,200 @@ const ImportDeviceModal: React.FC = ({
}
};
- const handleSubmit = async () => {
- setErrors({});
- if (mappingMode && parsedData.length > 0) {
- if (!formData.network) {
- showBanner({ severity: 'error', message: "Please select a Sensor Manufacturer.", scoped: true });
- return;
- }
- if (!formData.category) {
- showBanner({ severity: 'error', message: "Please select a Category for this import.", scoped: true });
- return;
- }
+ 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 missingRequired = EXPECTED_FIELDS.filter(f => f.required && !fieldMapping[f.key]);
- if (missingRequired.length > 0) {
- showBanner({ severity: 'error', message: `Please map the required fields: ${missingRequired.map(f => f.label).join(', ')}`, scoped: true });
- return;
- }
+ 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 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;
- }
+ 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 invalidAuthRows: number[] = [];
-
- const transformedDevices = parsedData.map((row, rowIndex) => {
- const device: Record = {};
- EXPECTED_FIELDS.forEach(field => {
- 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);
- }
+ 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 {
- device[field.key] = row[mappedHeader];
+ 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;
- }
+ 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;
- setTransformedPreview(transformedDevices);
- setMappingMode(false);
- setPreviewMode(true);
- return;
+ 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;
}
- if (!validateForm()) {
- return;
+ 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 deviceDataToSend = { ...formData };
+ const handleBack = () => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ };
- (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];
+ 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 cohortId = getCohortId();
+ const handleCompleteSingle = () => {
const userId = userDetails?._id;
+ if (!userId) return;
- if (!userId) {
- logger.warn("User ID is missing");
- showBanner({ severity: 'error', message: 'Unable to identify user. Please reload and try again.', scoped: true });
- 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,
- ...(cohortId && { cohort_id: cohortId }),
+ ...(selectedCohortId && { cohort_id: selectedCohortId }),
},
{
onSuccess: (data, variables) => {
- onOpenChange(false);
- showBannerWithDelay({
- severity: 'success',
- title: 'Success',
- message: `${variables.long_name.trim()} has been imported successfully.`,
- scoped: false
- }, 300);
+ 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 });
@@ -366,19 +478,13 @@ const ImportDeviceModal: React.FC = ({
);
};
- const handleConfirmImport = () => {
- setErrors({});
+ const handleCompleteBulk = () => {
const userId = userDetails?._id;
- if (!userId) {
- logger.warn("User ID is missing");
- showBanner({ severity: 'error', message: "User ID is missing. Please log in again.", scoped: true });
- return;
- }
- const cohortId = getCohortId();
+ if (!userId) return;
const payload = {
user_id: userId,
- ...(cohortId && { cohort_id: cohortId }),
+ ...(selectedCohortId && { cohort_id: selectedCohortId }),
...(formData.network && { network_override: formData.network }),
devices: transformedPreview
};
@@ -387,14 +493,26 @@ const ImportDeviceModal: React.FC = ({
{ type: 'json', payload },
{
onSuccess: (data) => {
- if (data.failed === 0) {
- onOpenChange(false);
- showBannerWithDelay({
- severity: 'success',
- title: 'Success',
- message: `${data.imported} device(s) imported successfully.`,
- scoped: false,
- }, 300);
+ 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({
@@ -411,14 +529,22 @@ const ImportDeviceModal: React.FC = ({
scoped: true,
});
}
-
if (data.results) {
setBulkResults(data);
- setPreviewMode(false);
- } else {
- onOpenChange(false);
+ 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({
@@ -432,467 +558,223 @@ const ImportDeviceModal: React.FC = ({
);
};
- const handleInputChange = (field: string, value: string | boolean) => {
- setFormData((prev) => ({
- ...prev,
- [field]: value,
- }));
-
- // Clear error when user starts typing
- if (errors[field]) {
- setErrors((prev) => ({
- ...prev,
- [field]: "",
- }));
+ const handleComplete = () => {
+ if (importFlow === 'bulk') {
+ handleCompleteBulk();
+ } else {
+ handleCompleteSingle();
}
};
- const handleClose = () => {
- onOpenChange(false);
+ const handleHeaderClick = (stepIndex: number) => {
+ if (stepIndex > currentStep) {
+ handleNext();
+ } else {
+ setCurrentStep(stepIndex);
+ }
};
- React.useEffect(() => {
- if (!open) {
- setFormData({
- long_name: "",
- network: prefilledNetwork || "",
- category: DEVICE_CATEGORIES[0].value,
- serial_number: "",
- description: "",
- device_number: "",
- writeKey: "",
- readKey: "",
- api_code: "",
- authRequired: true,
- tags: [],
- });
- setErrors({});
- setShowMore(false);
- setIsRequestDialogOpen(false);
- setBulkFile(null);
- setBulkResults(null);
- setParsedData([]);
- setFileHeaders([]);
- setFieldMapping({});
- setMappingMode(false);
- setPreviewMode(false);
- setTransformedPreview([]);
- hideBanner();
+ // 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
+ >
+ )
+ }
+ ];
}
- }, [open, prefilledNetwork, hideBanner]);
+ };
+
+ const steps = getSteps();
return (
- 0
- ? {
- label: "Download Failed CSV",
- onClick: downloadFailedRows,
- variant: "outline",
- }
- : bulkResults
- ? undefined
- : previewMode
- ? {
- label: "Back to Mapping",
- onClick: () => {
- setPreviewMode(false);
- setMappingMode(true);
- },
- disabled: importDevice.isPending || bulkImport.isPending,
- variant: "outline",
- }
- : mappingMode
- ? {
- label: "Cancel Mapping",
- onClick: () => handleFileUpload(null),
- disabled: importDevice.isPending || bulkImport.isPending,
- variant: "outline",
- }
- : {
- label: "Cancel",
- onClick: handleClose,
- disabled: importDevice.isPending || bulkImport.isPending,
- variant: "outline",
- }
- }
- >
-
- {bulkResults ? (
-
-
-
- {bulkResults.imported} of {bulkResults.total} devices imported. {bulkResults.failed} failed.
-
-
-
-
-
-
-
- Device Name
- Serial Number
- Status
-
-
-
- {bulkResults.results.map((result, idx) => (
-
- {result.long_name || '-'}
- {result.serial_number || '-'}
-
- {result.success ? (
- Success
- ) : (
- {result.error || 'Failed'}
- )}
-
-
- ))}
-
-
-
-
- ) : previewMode ? (
-
-
-
Previewing the first {Math.min(5, transformedPreview.length)} of {transformedPreview.length} devices. Please verify the data looks correct before importing.
-
-
- {errors.general && (
-
- {errors.general}
-
- )}
-
-
-
-
-
- Device Name
- Serial Number
- Manufacturer
- Category
- Authentication Required
- Latitude
- Longitude
-
-
-
- {transformedPreview.slice(0, 5).map((device, idx) => (
-
- {device.long_name || '-'}
- {device.serial_number || '-'}
- {device.network || '-'}
- {device.category || '-'}
- {device.authRequired === false ? 'No' : 'Yes'}
- {device.latitude || '-'}
- {device.longitude || '-'}
-
- ))}
-
-
-
-
- ) : mappingMode ? (
-
-
-
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) => (
-
-
-
- {field.label}
- {field.required && * }
-
-
-
- setFieldMapping(prev => ({ ...prev, [field.key]: e.target.value }))}
- >
- -- Ignore this field --
- {fileHeaders.map(header => (
- {header}
- ))}
-
-
-
- ))}
-
-
+ <>
+
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({});
+ }}
+ />
-
-
-
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}
+ ) : (
+
+ {steps.map((step, idx) => (
+
- {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)
- setFormData((prev) => ({ ...prev, tags }))}
- allowCreate={true}
- />
-
-
-
- ) : (
- <>
- {errors.general && (
-
- {errors.general}
-
- )}
-
-
-
-
-
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}
-
+ {step.content}
+
))}
-
-
- {!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
-
-
- 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}
- />
-
- Tags (Optional)
- setFormData((prev) => ({ ...prev, tags }))}
- allowCreate={true}
- />
-
-
- {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/components/features/devices/import-steps/BulkImportForm.tsx b/src/vertex/components/features/devices/import-steps/BulkImportForm.tsx
new file mode 100644
index 0000000000..2ea643f1e7
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/BulkResultsStep.tsx b/src/vertex/components/features/devices/import-steps/BulkResultsStep.tsx
new file mode 100644
index 0000000000..754c7cba31
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/CohortSelectionStep.tsx b/src/vertex/components/features/devices/import-steps/CohortSelectionStep.tsx
new file mode 100644
index 0000000000..653bd18d1d
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/ConfirmationStep.tsx b/src/vertex/components/features/devices/import-steps/ConfirmationStep.tsx
new file mode 100644
index 0000000000..92b1011b10
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/FieldMappingStep.tsx b/src/vertex/components/features/devices/import-steps/FieldMappingStep.tsx
new file mode 100644
index 0000000000..206445fb51
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/ImportMethodSelectStep.tsx b/src/vertex/components/features/devices/import-steps/ImportMethodSelectStep.tsx
new file mode 100644
index 0000000000..ecd6cd7225
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/ImportPreviewStep.tsx b/src/vertex/components/features/devices/import-steps/ImportPreviewStep.tsx
new file mode 100644
index 0000000000..1917dc893d
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/ImportSuccessStep.tsx b/src/vertex/components/features/devices/import-steps/ImportSuccessStep.tsx
new file mode 100644
index 0000000000..7fd60ab3e8
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/SingleImportForm.tsx b/src/vertex/components/features/devices/import-steps/SingleImportForm.tsx
new file mode 100644
index 0000000000..1e8c33686e
--- /dev/null
+++ b/src/vertex/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/components/features/devices/import-steps/types.ts b/src/vertex/components/features/devices/import-steps/types.ts
new file mode 100644
index 0000000000..964e6e8192
--- /dev/null
+++ b/src/vertex/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/components/features/download/DownloadHero.tsx b/src/vertex/components/features/download/DownloadHero.tsx
index 2b7804c148..4a72ed4588 100644
--- a/src/vertex/components/features/download/DownloadHero.tsx
+++ b/src/vertex/components/features/download/DownloadHero.tsx
@@ -98,7 +98,7 @@ export default function DownloadHero() {
Deployment checks
- {['Claim devices', 'Verify metadata', 'Share data'].map(
+ {['Add devices', 'Verify metadata', 'Share data'].map(
step => (
diff --git a/src/vertex/components/features/home/HomeEmptyState.tsx b/src/vertex/components/features/home/HomeEmptyState.tsx
index 83e89c5635..ef3f3aedbd 100644
--- a/src/vertex/components/features/home/HomeEmptyState.tsx
+++ b/src/vertex/components/features/home/HomeEmptyState.tsx
@@ -41,7 +41,7 @@ const HomeEmptyState = () => {
setIsClaimModalOpen(true)} Icon={Plus}>
- Claim AirQo Device
+ Add AirQo Device
{
const getContextDescription = () => {
switch (userContext) {
case "personal":
- return "Manage and monitor your personal devices.";
+ return "Devices and data here are owned by you.";
case "external-org":
return `View and manage all devices linked to the organization.`;
default:
@@ -43,7 +43,7 @@ const ContextHeader = () => {
+
You're in {getContextTitle()}. {" "}
{getContextDescription()}
diff --git a/src/vertex/components/features/home/network-visibility-card.tsx b/src/vertex/components/features/home/network-visibility-card.tsx
index 6162544a62..0dd90264d5 100644
--- a/src/vertex/components/features/home/network-visibility-card.tsx
+++ b/src/vertex/components/features/home/network-visibility-card.tsx
@@ -1,7 +1,11 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { Globe, Lock, Shield } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
-import { useCohorts, useGroupCohorts } from '@/core/hooks/useCohorts';
+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';
@@ -12,203 +16,315 @@ 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';
-const NetworkVisibilityCard = () => {
- const queryClient = useQueryClient();
- const router = useRouter();
- const [isDialogOpen, setIsDialogOpen] = useState(false);
- const [targetVisibility, setTargetVisibility] = useState(false);
- const [pendingCohort, setPendingCohort] = useState(null);
- const [isUpdating, setIsUpdating] = useState(false);
-
- const { activeGroup, isExternalOrg } = useUserContext();
-
- // 1. Fetch group cohort IDs to ensure we only target this org's cohorts
- const { data: groupCohortIds, isLoading: isLoadingGroupCohorts } = useGroupCohorts(
- activeGroup?._id,
- { enabled: isExternalOrg && !!activeGroup?._id }
- );
-
- const hasIdsToFetch = groupCohortIds && groupCohortIds.length > 0;
-
- // 2. Fetch details for these specific cohorts
- const { cohorts, isLoading: isLoadingCohorts } = useCohorts(
- {
- limit: 1000,
- cohort_id: hasIdsToFetch ? groupCohortIds : undefined
- },
- { enabled: !!hasIdsToFetch }
- );
-
- const isLoading = (isExternalOrg && isLoadingGroupCohorts) || isLoadingCohorts;
-
- const hasDeviceUpdatePermission = usePermissions([PERMISSIONS.DEVICE.UPDATE])[
- PERMISSIONS.DEVICE.UPDATE
- ];
-
- if (!hasDeviceUpdatePermission) {
- return null;
- }
+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;
- // Safely handle empty state (if loading finishes but no cohorts found)
- if (!isLoading && (!cohorts || cohorts.length === 0)) {
- return null; // Don't show card if no cohorts to manage
+ const hasDeviceUpdatePermission = usePermissions([PERMISSIONS.DEVICE.UPDATE])[
+ PERMISSIONS.DEVICE.UPDATE
+ ];
+
+ useEffect(() => {
+ if (showCoachMark) {
+ setIsCoachMarkVisible(true);
+ const t = setTimeout(() => setIsCoachMarkVisible(false), 5000);
+ return () => clearTimeout(t);
}
+ }, [showCoachMark]);
- 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 });
-
- ReusableToast({
- message: `Successfully set ${pendingCohort.name} to ${targetVisibility ? 'Public' : 'Private'}`,
- type: 'SUCCESS',
- });
-
- queryClient.invalidateQueries({ queryKey: ['cohorts'] });
-
- } catch (error) {
- console.error(error);
- ReusableToast({
- message: 'Failed to update network visibility',
- type: 'ERROR',
- });
- } finally {
- setIsUpdating(false);
- setIsDialogOpen(false);
- setPendingCohort(null);
- }
- };
+ 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);
- if (isLoading) {
- return null;
+ 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 ?
:
-
}
+ 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}
+
-
-
- {allPublic ? "Your devices are Public" :
- allPrivate ? "Your devices are Private" :
- "Custom Visibility Settings"}
-
-
- {allPublic ? "Your devices are visible to anyone on the AirQo Map. You are contributing to open data." :
- allPrivate ? "Your devices are hidden from the public. Data is visible only in your account." :
- "Visibility settings are customized per cohort. Manage each cohort below."}
-
+
+ ) : isCoachMarkVisible && !cohort.visibility && index === cohorts.findIndex(c => !c.visibility) && (
+
+
+ Toggle to make public
+ {/* Arrow pointing down */}
+
-
-
+
+ )}
-
-
Manage Cohorts Visibility
-
- {cohorts.map((cohort) => (
-
-
-
-
- {cohort.visibility ? 'Public' : 'Private'}
-
- {
- setPendingCohort(cohort);
- setTargetVisibility(checked);
- setIsDialogOpen(true);
- }}
- className="data-[state=checked]:bg-green-500"
- />
-
-
- ))}
+
+
+ {
+ 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
-
-
-
+
+ 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")}
-
-
- }
+ !isUpdating && setIsDialogOpen(false)}
+ title={
+ targetVisibility
+ ? `Make "${pendingCohort?.name}" public?`
+ : `Make "${pendingCohort?.name}" private?`
+ }
+ size="md"
+ customFooter={
+
+
setIsDialogOpen(false)}
+ disabled={isUpdating}
>
-
-
- {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 your organization and will not appear on the public map.`
- }
-
-
-
- >
- );
+ 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/components/features/home/onboarding-checklist.tsx b/src/vertex/components/features/home/onboarding-checklist.tsx
new file mode 100644
index 0000000000..6e727526b8
--- /dev/null
+++ b/src/vertex/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/components/features/org-picker/organization-modal.tsx b/src/vertex/components/features/org-picker/organization-modal.tsx
index b88d84baa1..0b6f368a4b 100644
--- a/src/vertex/components/features/org-picker/organization-modal.tsx
+++ b/src/vertex/components/features/org-picker/organization-modal.tsx
@@ -126,8 +126,8 @@ const OrganizationModal: React.FC = ({
zIndex={2000}
title="Organizations"
subtitle={`${filteredGroups.length} of ${userGroups?.length || 0} available`}
- maxHeight="max-h-[65vh]"
- contentClassName="overflow-hidden flex flex-col"
+ maxHeight="max-h-[55vh]"
+ contentClassName="flex flex-col"
contentAreaClassName="p-0 flex flex-col flex-1 min-h-0"
customFooter={
diff --git a/src/vertex/components/features/org-picker/organization-picker.tsx b/src/vertex/components/features/org-picker/organization-picker.tsx
index 3aeed274b2..e12921a802 100644
--- a/src/vertex/components/features/org-picker/organization-picker.tsx
+++ b/src/vertex/components/features/org-picker/organization-picker.tsx
@@ -17,9 +17,9 @@ 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 ReusableToast from "@/components/shared/toast/ReusableToast";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
-const formatTitle = (title: string) => {
+export const formatTitle = (title: string) => {
if (!title) return "";
return title.replace(/[_-]/g, " ").toUpperCase();
};
@@ -32,6 +32,7 @@ const OrganizationPicker: React.FC = () => {
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);
@@ -47,31 +48,22 @@ const OrganizationPicker: React.FC = () => {
const validUserGroups = useMemo(() => {
if (!Array.isArray(userGroups)) return [];
- return userGroups.filter(
- (group): group is Group => !!(group && group._id && group.grp_title)
- );
+ 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) => {
- // 1. Instant UI Feedback
setIsModalOpen(false);
- // Clear any stale forbidden state from previous context before switching.
dispatch(clearForbiddenState());
- // 2. Start Background Transition
- dispatch(setOrganizationSwitching({
- isSwitching: true,
- switchingTo: group.grp_title
- }));
-
const newContext: UserContext =
group.grp_title.toLowerCase() === "airqo"
? "personal"
: "external-org";
try {
- // 3. Optimized Background Cleanup (Non-blocking)
- // We don't await these to prevent UI blocking
+ // Optimized Background Cleanup (Non-blocking)
const orgScopedQueryKeys = [
["devices"],
["myDevices"],
@@ -93,21 +85,24 @@ const OrganizationPicker: React.FC = () => {
queryClient.removeQueries({ queryKey });
});
- // 4. Update Redux State
+ // Start shimmer just before navigation fires
+ dispatch(setOrganizationSwitching({ isSwitching: true, switchingTo: group.grp_title }));
+
dispatch(setActiveGroup(group));
dispatch(setUserContext(newContext));
- // 5. Trigger Navigation
if (pathname === "/home") {
- // If already on home, we must clear immediately as pathname won't change
dispatch(setOrganizationSwitching({ isSwitching: false, switchingTo: "" }));
router.refresh();
} else {
router.push("/home");
}
} catch {
- ReusableToast({ message: "Failed to switch organization", type: "ERROR" });
- dispatch(setOrganizationSwitching({ isSwitching: false, switchingTo: "" }));
+ showBannerWithDelay({
+ severity: 'error',
+ message: 'Failed to switch organization. Please try again.',
+ scoped: false,
+ });
}
};
diff --git a/src/vertex/components/layout/layout.tsx b/src/vertex/components/layout/layout.tsx
index 5ce0ad85ce..40440fff5a 100644
--- a/src/vertex/components/layout/layout.tsx
+++ b/src/vertex/components/layout/layout.tsx
@@ -10,7 +10,6 @@ import SecondarySidebar from './secondary-sidebar';
import SessionLoadingState from './loading/session-loading';
import ErrorBoundary from '../shared/ErrorBoundary';
import Footer from './Footer';
-import { OrganizationSetupBanner } from './organization-setup-banner';
import { FeedbackLauncher } from '../features/feedback/feedback-launcher';
import { PageSatisfactionBanner } from '../features/feedback/page-satisfaction-banner';
import { GlobalBannerContainer } from '@/context/banner-context';
@@ -64,7 +63,6 @@ export default function Layout({ children }: LayoutProps) {
const routesToPrefetch = [
'/home',
'/devices/my-devices',
- '/devices/claim',
'/cohorts',
'/admin/networks',
'/admin/networks/requests',
@@ -120,7 +118,6 @@ export default function Layout({ children }: LayoutProps) {
)}
-
setIsPrimarySidebarOpen(true)} />
{
- const queryClient = useQueryClient();
- const { isExternalOrg, activeGroup } = useUserContext();
- const [isProcessing, setIsProcessing] = useState(false);
- const [isCompleted, setIsCompleted] = useState(false);
-
- const { data: cohorts, isLoading: isLoadingCohorts } = useGroupCohorts(
- activeGroup?._id,
- { enabled: isExternalOrg && !!activeGroup?._id }
- );
- const { mutate: assignToGroup } = useAssignCohortsToGroup();
- const { mutate: createCohort } = useCreateCohort();
-
- // No longer allowing dismissal without completion
-
- const handleCreateDefault = () => {
- if (!activeGroup?._id || !activeGroup?.grp_title) return;
-
- setIsProcessing(true);
- const cohortName = `${activeGroup.grp_title} Cohort`;
-
- createCohort(
- {
- name: cohortName,
- network: 'airqo',
- cohort_tags: [
- DEFAULT_COHORT_TAGS.find((tag) => tag.value === 'organizational')
- ?.value || 'organizational',
- ],
- },
- {
- onSuccess: (data) => {
- handleLinkCohort(data.cohort._id);
- },
- onError: () => {
- setIsProcessing(false);
- },
- }
- );
- };
-
- const handleLinkCohort = (cohortId: string) => {
- if (!activeGroup?._id) return;
-
- setIsProcessing(true);
- assignToGroup({
- groupId: activeGroup._id,
- cohortIds: [cohortId]
- }, {
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: ['groupCohorts', activeGroup._id],
- });
- setIsProcessing(false);
- setIsCompleted(true);
- },
- onError: () => {
- setIsProcessing(false);
- },
- });
- };
-
- if (!isExternalOrg || isLoadingCohorts || (cohorts && cohorts.length > 0) || isCompleted) {
- return null;
- }
-
- return (
- {}}
- onConfirmCreate={handleCreateDefault}
- onConfirmLink={handleLinkCohort}
- isProcessing={isProcessing}
- preventDismiss={true}
- />
- );
-};
diff --git a/src/vertex/components/layout/primary-sidebar.tsx b/src/vertex/components/layout/primary-sidebar.tsx
index f9bc943701..de02eec30e 100644
--- a/src/vertex/components/layout/primary-sidebar.tsx
+++ b/src/vertex/components/layout/primary-sidebar.tsx
@@ -1,7 +1,8 @@
'use client';
import React, { useState } from 'react';
-import { X, LayoutGrid, ShieldCheck, ChevronDown, ChevronRight, Clock } from 'lucide-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';
@@ -138,7 +139,19 @@ const PrimarySidebar: React.FC = ({
width={40}
height={40}
/>
- Vertex
+
+
Vertex
+ {activeGroup?.grp_title && (
+
+
+ {activeGroup.grp_title.replace(/[_-]/g, " ")}
+
+
+ )}
+
@@ -146,12 +159,12 @@ const PrimarySidebar: React.FC = ({
- {/* Device Management - visible to ALL users */}
+ {/* Home - visible to ALL users */}
{
diff --git a/src/vertex/components/layout/secondary-sidebar.tsx b/src/vertex/components/layout/secondary-sidebar.tsx
index a2400c089e..677f7d9dc2 100644
--- a/src/vertex/components/layout/secondary-sidebar.tsx
+++ b/src/vertex/components/layout/secondary-sidebar.tsx
@@ -98,7 +98,7 @@ const SecondarySidebar: React.FC = ({
overflow
overflowType="auto"
contentClassName={`flex flex-col h-full overflow-x-hidden scrollbar-thin ${styles.scrollbar}`}
- footer={!isCollapsed && !isElectron && platform === 'win' && (
+ footer={!isCollapsed && activeModule !== 'admin' && !isElectron && platform === 'win' && (
= ({
isCollapsed={isCollapsed}
onClick={onNavigate}
/>
-
+
Data Access & Visibility
@@ -202,15 +194,7 @@ const SecondarySidebar: React.FC = ({
isCollapsed={isCollapsed}
onClick={onNavigate}
/>
-
+
Data Access & Visibility
diff --git a/src/vertex/components/layout/topbar.tsx b/src/vertex/components/layout/topbar.tsx
index 5d88f2f121..01bc9b6e29 100644
--- a/src/vertex/components/layout/topbar.tsx
+++ b/src/vertex/components/layout/topbar.tsx
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { useAppSelector } from '@/core/redux/hooks';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { useRouter } from 'next/navigation';
+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';
@@ -38,7 +38,10 @@ const Topbar: React.FC = ({ onMenuClick }) => {
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 });
@@ -114,6 +117,11 @@ const Topbar: React.FC = ({ onMenuClick }) => {
className={`flex items-center justify-center text-gray-800`}
/>
Vertex
+ {isAdminMode && (
+
+ Administrator
+
+ )}
diff --git a/src/vertex/components/shared/dialog/ReusableDialog.tsx b/src/vertex/components/shared/dialog/ReusableDialog.tsx
index d923ffbc65..ba701fa1a5 100644
--- a/src/vertex/components/shared/dialog/ReusableDialog.tsx
+++ b/src/vertex/components/shared/dialog/ReusableDialog.tsx
@@ -96,6 +96,8 @@ const ReusableDialog: React.FC
= ({
const previousActiveElement = useRef(null)
const { hideBanner } = useBanner()
+
+
// Focus management
useEffect(() => {
if (isOpen) {
diff --git a/src/vertex/components/ui/accordion.tsx b/src/vertex/components/ui/accordion.tsx
index 94fe610929..073507d5ae 100644
--- a/src/vertex/components/ui/accordion.tsx
+++ b/src/vertex/components/ui/accordion.tsx
@@ -12,18 +12,21 @@ function Accordion({
return
}
-function AccordionItem({
- className,
- ...props
-}: React.ComponentProps) {
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(function AccordionItem({ className, ...props }, ref) {
return (
- )
-}
+ );
+});
+
+AccordionItem.displayName = "AccordionItem";
function AccordionTrigger({
className,
diff --git a/src/vertex/components/ui/banner.tsx b/src/vertex/components/ui/banner.tsx
index 88023e4971..d1f667579a 100644
--- a/src/vertex/components/ui/banner.tsx
+++ b/src/vertex/components/ui/banner.tsx
@@ -42,25 +42,25 @@ const SEVERITY_CONFIG: Record<
}
> = {
success: {
- bgColor: 'bg-emerald-100 dark:bg-emerald-950/20',
- borderColor: 'border-emerald-200 dark:border-emerald-800',
- textColor: 'text-emerald-800 dark:text-emerald-200',
+ 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-600 dark:text-emerald-400',
+ iconColor: 'text-emerald-700 dark:text-emerald-300',
},
error: {
- bgColor: 'bg-destructive/20',
- borderColor: 'border-destructive/30',
- textColor: 'text-destructive',
+ 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-destructive',
+ iconColor: 'text-red-700 dark:text-red-300',
},
warning: {
- bgColor: 'bg-amber-100 dark:bg-amber-950/20',
- borderColor: 'border-amber-200 dark:border-amber-800',
- textColor: 'text-amber-800 dark:text-amber-200',
+ 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-600 dark:text-amber-400',
+ iconColor: 'text-amber-700 dark:text-amber-300',
},
info: {
bgColor: 'bg-blue-100 dark:bg-blue-950/20',
@@ -95,7 +95,7 @@ export const Banner: React.FC = ({
return (
= ({
{/* Content */}
- {title &&
{title} }
+ {title &&
{title} }
{message && (
{message}
)}
diff --git a/src/vertex/context/page-title-context.tsx b/src/vertex/context/page-title-context.tsx
index 67b43a1d6b..b196e5aa7d 100644
--- a/src/vertex/context/page-title-context.tsx
+++ b/src/vertex/context/page-title-context.tsx
@@ -50,7 +50,6 @@ const getFallbackTitle = (pathname: string | null): PageTitleValue => {
const exactRouteTitles: Record
= {
"/home": { title: "Home" },
"/devices/my-devices": { title: "My Devices", section: "Devices" },
- "/devices/claim": { title: "Claim Device", section: "Devices" },
"/devices/overview": { title: "Devices" },
"/sites/overview": { title: "Sites" },
"/cohorts": { title: "Cohorts" },
diff --git a/src/vertex/core/apis/cohorts.ts b/src/vertex/core/apis/cohorts.ts
index 01725962b9..e990c3fedc 100644
--- a/src/vertex/core/apis/cohorts.ts
+++ b/src/vertex/core/apis/cohorts.ts
@@ -1,4 +1,4 @@
-import { Cohort, CohortsSummaryResponse, GroupCohortsResponse, OriginalCohortResponse } from "@/app/types/cohorts";
+import { Cohort, CohortsSummaryResponse, GroupCohortsResponse, OriginalCohortResponse, PersonalUserCohortsResponse } from "@/app/types/cohorts";
import createSecureApiClient from "../utils/secureApiProxyClient";
export interface GetCohortsSummaryParams {
@@ -101,6 +101,23 @@ export const cohorts = {
throw error;
}
},
+ unassignCohortsFromGroup: async (groupId: string, cohortIds: string[]) => {
+ try {
+ const response = await createSecureApiClient().delete(
+ `/users/groups/${groupId}/cohorts/unassign`,
+ {
+ headers: {
+ "Content-Type": "application/json",
+ "X-Auth-Type": "JWT",
+ },
+ data: { cohort_ids: cohortIds },
+ }
+ );
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+ },
assignCohortsToUser: async (userId: string, cohortIds: string[]) => {
try {
const response = await createSecureApiClient().post(
@@ -237,4 +254,18 @@ export const cohorts = {
throw error;
}
},
+ getPersonalUserCohorts: async (userId: string, signal?: AbortSignal): Promise => {
+ try {
+ if (!userId?.trim()) {
+ throw new Error('User ID is required');
+ }
+ const response = await createSecureApiClient().get(
+ `/users/${userId}/cohorts`,
+ { headers: { 'X-Auth-Type': 'JWT' }, signal }
+ );
+ return response.data;
+ } catch (error) {
+ throw error;
+ }
+ },
};
diff --git a/src/vertex/core/apis/organizations.ts b/src/vertex/core/apis/organizations.ts
index 00f0f7e8c8..25ba18af93 100644
--- a/src/vertex/core/apis/organizations.ts
+++ b/src/vertex/core/apis/organizations.ts
@@ -1,3 +1,4 @@
+import { CohortGroupsResponse } from "@/app/types/groups";
import createSecureApiClient from "../utils/secureApiProxyClient";
export const groupsApi = {
@@ -9,4 +10,12 @@ export const groupsApi = {
throw error;
}
},
+ getGroupsByCohortApi: async (cohortId: string): Promise => {
+ try {
+ const response = await createSecureApiClient().get(`/users/groups?cohort_id=${cohortId}`, { headers: { 'X-Auth-Type': 'JWT' } });
+ return response.data as CohortGroupsResponse;
+ } catch (error) {
+ throw error;
+ }
+ },
};
diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx
index 78e099094e..74bf373ec7 100644
--- a/src/vertex/core/auth/authProvider.tsx
+++ b/src/vertex/core/auth/authProvider.tsx
@@ -533,7 +533,7 @@ function AutoLogoutHandler() {
const { data: session } = useSession();
const logout = useLogout();
const lastActivityRef = useRef(Date.now());
- const INACTIVITY_LIMIT = 30 * 60 * 1000; // 30 minutes
+ const INACTIVITY_LIMIT = 6 * 60 * 60 * 1000; // 6 hours
const updateActivity = useCallback(() => {
lastActivityRef.current = Date.now();
@@ -562,7 +562,7 @@ function AutoLogoutHandler() {
const intervalId = setInterval(() => {
const timeSinceLastActivity = Date.now() - lastActivityRef.current;
if (timeSinceLastActivity >= INACTIVITY_LIMIT) {
- logger.debug('[AutoLogoutHandler] User inactive for 30 minutes, logging out');
+ logger.debug('[AutoLogoutHandler] User inactive for 6 hours, logging out');
logout();
}
}, 60 * 1000); // Check every minute
diff --git a/src/vertex/core/hooks/useCohorts.ts b/src/vertex/core/hooks/useCohorts.ts
index dc2e7b174d..12f8509651 100644
--- a/src/vertex/core/hooks/useCohorts.ts
+++ b/src/vertex/core/hooks/useCohorts.ts
@@ -123,6 +123,38 @@ export const useGroupCohorts = (
});
};
+export const usePersonalUserCohorts = (
+ userId?: string,
+ options: { enabled?: boolean } = {}
+) => {
+ const { enabled = true } = options;
+ return useQuery({
+ queryKey: ['personalUserCohorts', userId],
+ queryFn: async ({ signal }: QueryFunctionContext) => {
+ if (!userId) throw new Error('User ID is required');
+ const resp = await cohortsApi.getPersonalUserCohorts(userId, signal);
+ const ids: string[] = resp?.cohorts ?? [];
+
+ if (!ids || ids.length === 0) return [];
+
+ const verifyResults = await Promise.all(
+ ids.map((id) => cohortsApi.verifyCohortIdApi(id).catch(() => null))
+ );
+
+ const filteredIds = ids.filter((id, idx) => {
+ const result = verifyResults[idx];
+ if (!result) return false;
+ const name = (result?.cohort?.name || '').toLowerCase();
+ return name !== 'airqo';
+ });
+
+ return filteredIds;
+ },
+ enabled: !!userId && enabled,
+ staleTime: 60_000, // 1 minute - personal context refreshes faster
+ });
+};
+
type UseCohortDetailsOptions = { enabled?: boolean };
export const useCohortDetails = (
@@ -233,6 +265,7 @@ export const useCreateCohort = (options?: UseCreateCohortOptions) => {
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['cohorts'] });
queryClient.invalidateQueries({ queryKey: ['user-cohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts'] });
options?.onSuccess?.(data);
},
onError: (error: AxiosError) => {
@@ -244,6 +277,7 @@ export const useCreateCohort = (options?: UseCreateCohortOptions) => {
interface UseCreateCohortWithDevicesOptions {
onSuccess?: (data: { success: boolean; message: string; cohort: Cohort }) => void;
onError?: (error: AxiosError) => void;
+ invalidateOnSuccess?: boolean;
}
export const useCreateCohortWithDevices = (options?: UseCreateCohortWithDevicesOptions) => {
@@ -255,11 +289,15 @@ export const useCreateCohortWithDevices = (options?: UseCreateCohortWithDevicesO
network,
deviceIds,
cohort_tags,
+ groupId,
+ userId,
}: {
name: string;
network: string;
deviceIds: string[];
cohort_tags?: string[];
+ groupId?: string;
+ userId?: string;
}) => {
const createResp = await cohortsApi.createCohort({ name, network, cohort_tags });
const cohortId = createResp?.cohort?._id;
@@ -267,12 +305,23 @@ export const useCreateCohortWithDevices = (options?: UseCreateCohortWithDevicesO
if (Array.isArray(deviceIds) && deviceIds.length > 0) {
await cohortsApi.assignDevicesToCohort(cohortId, deviceIds);
}
+
+ if (groupId) {
+ await cohortsApi.assignCohortsToGroup(groupId, [cohortId]);
+ } else if (userId) {
+ await cohortsApi.assignCohortsToUser(userId, [cohortId]);
+ }
+
return createResp;
},
onSuccess: (data) => {
+ options?.onSuccess?.(data);
+ if (options?.invalidateOnSuccess === false) return;
+
queryClient.invalidateQueries({ queryKey: ['cohorts'] });
queryClient.invalidateQueries({ queryKey: ['user-cohorts'] });
- options?.onSuccess?.(data);
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['groupCohorts'] });
},
onError: (error: AxiosError) => {
options?.onError?.(error);
@@ -312,6 +361,7 @@ export const useCreateCohortFromCohorts = (options?: UseCreateCohortFromCohortsO
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['cohorts'] });
queryClient.invalidateQueries({ queryKey: ['user-cohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts'] });
options?.onSuccess?.(data);
},
onError: (error: AxiosError) => {
@@ -430,6 +480,42 @@ export const useAssignCohortsToGroup = (options?: UseAssignCohortsToGroupOptions
});
};
+interface UseUnassignCohortsFromGroupOptions {
+ onSuccess?: () => void;
+ onError?: (error: AxiosError) => void;
+}
+
+export const useUnassignCohortsFromGroup = (options?: UseUnassignCohortsFromGroupOptions) => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ groupId,
+ cohortIds,
+ }: {
+ groupId: string;
+ cohortIds: string[];
+ }) => {
+ if (!groupId) {
+ throw new Error('Group ID is required');
+ }
+ if (!cohortIds?.length) {
+ throw new Error('At least one cohort ID is required');
+ }
+ return cohortsApi.unassignCohortsFromGroup(groupId, cohortIds);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['cohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['groupCohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['cohort-details'] });
+ options?.onSuccess?.();
+ },
+ onError: (error: AxiosError) => {
+ options?.onError?.(error);
+ },
+ });
+};
+
interface UseAssignCohortsToUserOptions {
onSuccess?: (variables: { userId: string; cohortIds: string[] }) => void;
onError?: (error: AxiosError) => void;
diff --git a/src/vertex/core/hooks/useContextAwareRouting.ts b/src/vertex/core/hooks/useContextAwareRouting.ts
index ba790c93db..33ced6c4f9 100644
--- a/src/vertex/core/hooks/useContextAwareRouting.ts
+++ b/src/vertex/core/hooks/useContextAwareRouting.ts
@@ -12,7 +12,6 @@ const routeToSidebarConfig: Record = {
'/access-control': 'showAccessControl',
[ROUTE_LINKS.MY_DEVICES]: 'showMyDevices',
[ROUTE_LINKS.ORG_ASSETS]: 'showDeviceOverview',
- '/devices/claim': 'showClaimDevice',
[ROUTE_LINKS.GRIDS]: 'showGrids',
[ROUTE_LINKS.COHORTS]: 'showCohorts',
[ROUTE_LINKS.ADMIN_NETWORKS]: 'showNetworks',
@@ -52,8 +51,6 @@ export const useContextAwareRouting = () => {
return true;
};
- const contextChanged =
- initializedRef.current && previousContextRef.current !== userContext;
initializedRef.current = true;
previousContextRef.current = userContext;
diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts
index 8e80a8da75..72958ea8c8 100644
--- a/src/vertex/core/hooks/useDevices.ts
+++ b/src/vertex/core/hooks/useDevices.ts
@@ -13,7 +13,7 @@ import {
DeviceCountResponse,
type DeviceActivitiesResponse,
} from '../apis/devices';
-import { useGroupCohorts } from './useCohorts';
+import { useGroupCohorts, usePersonalUserCohorts } from './useCohorts';
import { useAppSelector } from '../redux/hooks';
import { useMemo } from 'react';
import type {
@@ -42,7 +42,6 @@ import type {
ShippingBatchDetailsResponse,
} from '@/app/types/devices';
import { AxiosError } from 'axios';
-import { useDispatch } from 'react-redux';
import ReusableToast from '@/components/shared/toast/ReusableToast';
import { getApiErrorMessage } from '../utils/getApiErrorMessage';
import logger from '@/lib/logger';
@@ -189,14 +188,16 @@ export const useMyDevices = (
grp_title?: string;
};
+ const { data: personalCohortIds, isLoading: isPersonalCohortsLoading } = usePersonalUserCohorts(userId, { enabled: !!userId && enabled });
+
const groupIds = userDetails?.groups
? (userDetails.groups as UserGroup[])
.filter((g) => g.grp_title?.toLowerCase() !== "airqo")
.map((g) => g._id)
: userDetails?.group_ids || [];
- const cohortIds = userDetails?.cohort_ids || [];
+ const cohortIds = personalCohortIds && personalCohortIds.length > 0 ? personalCohortIds : (userDetails?.cohort_ids || []);
- return useQuery>({
+ const query = useQuery>({
queryKey: [
"myDevices",
userId,
@@ -205,37 +206,55 @@ export const useMyDevices = (
cohortIds,
],
queryFn: () => devices.getMyDevices(userId, groupIds, cohortIds),
- enabled: !!userId && enabled && !!userDetails,
+ enabled: !!userId && enabled && !!userDetails && !isPersonalCohortsLoading,
staleTime: 60_000, // 1 minute
});
+
+ return {
+ ...query,
+ isLoading: query.isLoading || isPersonalCohortsLoading,
+ };
};
export const useDeviceCount = (options: { enabled?: boolean; cohortIds?: string[]; network?: string } = {}) => {
const activeGroup = useAppSelector(state => state.user.activeGroup);
const { enabled = true, cohortIds, network } = options;
- const isAirQoGroup = activeGroup?.grp_title === 'airqo';
- const shouldFetchGroupCohorts = !cohortIds && !isAirQoGroup && !!activeGroup?._id && enabled && !network;
+ // If cohortIds are explicitly passed (e.g. personal scope), bypass group logic entirely
+ const hasExplicitCohorts = !!cohortIds;
+
+ // Only treat as AirQo group if no explicit cohortIds were passed
+ const isAirQoGroup = !hasExplicitCohorts && activeGroup?.grp_title === 'airqo';
+
+ const shouldFetchGroupCohorts =
+ !hasExplicitCohorts &&
+ !isAirQoGroup &&
+ !!activeGroup?._id &&
+ enabled &&
+ !network;
const { data: groupCohortIds, isLoading: isLoadingCohorts } = useGroupCohorts(
activeGroup?._id,
- {
- enabled: shouldFetchGroupCohorts,
- }
+ { enabled: shouldFetchGroupCohorts }
);
- const effectiveCohortIds = cohortIds || groupCohortIds;
+ // Explicit cohortIds take priority over group-derived ones
+ const effectiveCohortIds = hasExplicitCohorts ? cohortIds : groupCohortIds;
const isQueryEnabled =
enabled &&
- (!!network || isAirQoGroup || (!!effectiveCohortIds && effectiveCohortIds.length > 0));
+ (
+ !!network ||
+ isAirQoGroup ||
+ (!!effectiveCohortIds && effectiveCohortIds.length > 0)
+ );
const query = useQuery>({
queryKey: [
'deviceCount',
- activeGroup?._id,
+ hasExplicitCohorts ? 'personal' : activeGroup?._id,
isAirQoGroup ? null : effectiveCohortIds,
- network
+ network,
],
queryFn: () => {
if (network) {
@@ -249,10 +268,11 @@ export const useDeviceCount = (options: { enabled?: boolean; cohortIds?: string[
if (!effectiveCohortIds || effectiveCohortIds.length === 0) {
return Promise.reject(new Error('Cohort IDs must be provided.'));
}
+
return devices.getDeviceCountApi({ cohort_id: effectiveCohortIds });
},
enabled: isQueryEnabled,
- staleTime: 300_000, // 5 minutes
+ staleTime: 300_000,
refetchOnWindowFocus: false,
});
@@ -606,6 +626,9 @@ export const useImportDevice = () => {
queryClient.invalidateQueries({ queryKey: ['deviceCount'] });
queryClient.invalidateQueries({ queryKey: ['deviceActivities'] });
}
+ queryClient.invalidateQueries({ queryKey: ['cohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['groupCohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts'] });
}
}
});
@@ -643,6 +666,9 @@ export const useBulkImportDevices = () => {
queryClient.invalidateQueries({ queryKey: ['deviceCount'] });
queryClient.invalidateQueries({ queryKey: ['deviceActivities'] });
}
+ queryClient.invalidateQueries({ queryKey: ['cohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['groupCohorts'] });
+ queryClient.invalidateQueries({ queryKey: ['personalUserCohorts'] });
}
},
});
diff --git a/src/vertex/core/hooks/useGroups.ts b/src/vertex/core/hooks/useGroups.ts
index be61c3a80c..f0a603a006 100644
--- a/src/vertex/core/hooks/useGroups.ts
+++ b/src/vertex/core/hooks/useGroups.ts
@@ -28,3 +28,18 @@ export const useGroups = () => {
};
};
+export const useGroupsByCohort = (cohortId: string) => {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ["groups", "cohort", cohortId],
+ queryFn: () => groupsApi.getGroupsByCohortApi(cohortId),
+ enabled: !!cohortId,
+ });
+
+ return {
+ groups: data?.groups ?? [],
+ isLoading,
+ error,
+ refetch,
+ };
+};
+
diff --git a/src/vertex/core/hooks/useRecentlyVisited.ts b/src/vertex/core/hooks/useRecentlyVisited.ts
index b2052e868e..2e54d5b349 100644
--- a/src/vertex/core/hooks/useRecentlyVisited.ts
+++ b/src/vertex/core/hooks/useRecentlyVisited.ts
@@ -35,7 +35,6 @@ const getCanonicalRoute = (pathname: string): { label: string; href: string } |
if (pathname.includes('/devices/my-devices')) return { label: 'My Devices', href: ROUTE_LINKS.MY_DEVICES };
if (pathname.includes('/devices/overview')) return { label: 'Device Overview', href: ROUTE_LINKS.ORG_ASSETS };
- if (pathname.includes('/devices/claim')) return { label: 'Claim Device', href: ROUTE_LINKS.ORG_REGISTER_DEVICE };
return null;
};
diff --git a/src/vertex/core/permissions/permissionService.ts b/src/vertex/core/permissions/permissionService.ts
index 8923865424..6951a54c75 100644
--- a/src/vertex/core/permissions/permissionService.ts
+++ b/src/vertex/core/permissions/permissionService.ts
@@ -400,7 +400,7 @@ class PermissionService {
[PERMISSIONS.DEVICE.MAINTAIN]: "Perform device maintenance",
[PERMISSIONS.DEVICE.UPDATE]: "Update device configuration",
[PERMISSIONS.DEVICE.DELETE]: "Delete device records",
- [PERMISSIONS.DEVICE.CLAIM]: "Claim device",
+ [PERMISSIONS.DEVICE.CLAIM]: "Add AirQo device",
[PERMISSIONS.SITE.VIEW]: "View site information",
[PERMISSIONS.SITE.CREATE]: "Create new sites",
diff --git a/src/vertex/core/routes.ts b/src/vertex/core/routes.ts
index 91e8d50259..ec2dadad53 100644
--- a/src/vertex/core/routes.ts
+++ b/src/vertex/core/routes.ts
@@ -10,7 +10,6 @@ export const ROUTE_LINKS = {
MY_DEVICES: '/devices/my-devices',
ORG_OVERVIEW: '/home',
ORG_ASSETS: '/devices/overview',
- ORG_REGISTER_DEVICE: '/devices/claim',
DEVICE_COHORTS: '/cohorts',
ORG_SITES: '/sites/overview',
SITE_DETAILS: '/sites',
diff --git a/src/vertex/core/urls.tsx b/src/vertex/core/urls.tsx
index 83aa369cff..824ac28998 100644
--- a/src/vertex/core/urls.tsx
+++ b/src/vertex/core/urls.tsx
@@ -1,6 +1,11 @@
import { stripTrailingSlash } from "@/lib/utils";
-const DEFAULT_ANALYTICS_BASE_URL = "https://staging-analytics.airqo.net";
+const isProduction =
+ process.env.NEXT_PUBLIC_ENV === "production" ||
+ process.env.NODE_ENV === "production";
+const DEFAULT_ANALYTICS_BASE_URL = isProduction
+ ? "https://analytics.airqo.net"
+ : "https://staging-analytics.airqo.net";
export const BASE_API_URL = stripTrailingSlash(
process.env.NEXT_PUBLIC_API_URL || ""
diff --git a/src/vertex/core/utils/sessionManager.ts b/src/vertex/core/utils/sessionManager.ts
index 7cb0b6a733..811eadd45e 100644
--- a/src/vertex/core/utils/sessionManager.ts
+++ b/src/vertex/core/utils/sessionManager.ts
@@ -1,34 +1,36 @@
-/**
- * Clears all session-related data from localStorage, using a secure default-deny approach.
- * Only whitelisted keys (like cross-tab signals or remembered accounts) are preserved.
- */
-export const clearSessionData = () => {
- if (typeof window === 'undefined') return;
-
- const keysToRemove = new Set();
-
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- if (!key) continue;
-
- // Whitelist specific keys to keep
- if (
- key.startsWith('next-auth') ||
- key === 'vertex_remembered_accounts_v1' ||
- key.startsWith('lastActiveModule') ||
- key.startsWith('lastActiveGroupId') ||
- key.startsWith('vertex_login_feedback')
- ) {
- continue;
- }
-
- keysToRemove.add(key);
- }
-
- for (const key of Array.from(keysToRemove)) {
- localStorage.removeItem(key);
- }
-
- // Clear sessionStorage as well
- sessionStorage.clear();
-};
\ No newline at end of file
+/**
+ * Clears all session-related data from localStorage, using a secure default-deny approach.
+ * Only whitelisted keys (like cross-tab signals or remembered accounts) are preserved.
+ */
+export const clearSessionData = () => {
+ if (typeof window === 'undefined') return;
+
+ const keysToRemove = new Set();
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (!key) continue;
+
+ // Whitelist specific keys to keep
+ if (
+ key.startsWith('next-auth') ||
+ key === 'vertex_remembered_accounts_v1' ||
+ key.startsWith('lastActiveModule') ||
+ key.startsWith('lastActiveGroupId') ||
+ key.startsWith('vertex_login_feedback') ||
+ key.startsWith('vertex_onboarding') ||
+ key === 'vertex_cookies_accepted'
+ ) {
+ continue;
+ }
+
+ keysToRemove.add(key);
+ }
+
+ for (const key of Array.from(keysToRemove)) {
+ localStorage.removeItem(key);
+ }
+
+ // Clear sessionStorage as well
+ sessionStorage.clear();
+};
diff --git a/src/vertex/tailwind.config.ts b/src/vertex/tailwind.config.ts
index 2d83879d14..6b5179b4be 100644
--- a/src/vertex/tailwind.config.ts
+++ b/src/vertex/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/website/README.md b/src/website/README.md
index f6ed2e4108..e73b563160 100644
--- a/src/website/README.md
+++ b/src/website/README.md
@@ -1,4 +1,4 @@
-# Website.
+# Website
Welcome to the Website repository, part of the AirQo Frontend project. This website is built with [Next.js](https://nextjs.org) and was bootstrapped using [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). The live website can be found at [airqo.net](https://airqo.net)