diff --git a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx
index a2393823d3..3ec52f600e 100644
--- a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx
+++ b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx
@@ -4,7 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { AqArrowLeft } from "@airqo/icons-react";
import ReusableButton from "@/components/shared/button/ReusableButton";
-import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites";
+import { useSiteDetails } from "@/core/hooks/useSites";
+import { useRefreshMetadataWithBanner } from "@/core/hooks/useRefreshMetadataWithBanner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useParams } from "next/navigation";
@@ -29,7 +30,7 @@ export default function SiteDetailsPage() {
const params = useParams();
const siteId = params.id as string;
const { data: site, isLoading, error } = useSiteDetails(siteId);
- const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata();
+ const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshMetadataWithBanner();
const router = useRouter();
const [editSection, setEditSection] = useState<"general" | "mobile" | null>(
null
diff --git a/src/vertex/app/(authenticated)/sites/[id]/page.tsx b/src/vertex/app/(authenticated)/sites/[id]/page.tsx
index 39bea20b74..43147b6408 100644
--- a/src/vertex/app/(authenticated)/sites/[id]/page.tsx
+++ b/src/vertex/app/(authenticated)/sites/[id]/page.tsx
@@ -4,7 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { AqArrowLeft } from "@airqo/icons-react";
import ReusableButton from "@/components/shared/button/ReusableButton";
-import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites";
+import { useSiteDetails } from "@/core/hooks/useSites";
+import { useRefreshMetadataWithBanner } from "@/core/hooks/useRefreshMetadataWithBanner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
import { useParams } from "next/navigation";
@@ -31,7 +32,7 @@ export default function UserSiteDetailsPage() {
const params = useParams();
const siteId = params.id as string;
const { data: site, isLoading, error } = useSiteDetails(siteId);
- const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata();
+ const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshMetadataWithBanner();
const router = useRouter();
const [editSection, setEditSection] = useState<"general" | "mobile" | null>(
null
diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md
index 487c44b441..38e7b0e2c3 100644
--- a/src/vertex/app/changelog.md
+++ b/src/vertex/app/changelog.md
@@ -4,6 +4,39 @@
---
+## Version 2.0.2
+**Released:** June 10, 2026
+
+### Site Management Banner Migration
+
+Migrated all user-facing notifications in the Site Management module from `ReusableToast` and Sonner `toast` to the centralized `useBanner` / `useBannerWithDelay` / `useClipboard` system.
+
+
+Changes (4)
+
+- **Hooks Decoupled**: Removed `ReusableToast` from 4 mutation hooks in `useSites.ts` — `useApproximateCoordinates`, `useUpdateSiteDetails`, `useCreateSite`, `useRefreshSiteMetadata` — and replaced with optional `onSuccess`/`onError` callback interfaces. Cache invalidation logic stays in the hooks; notification responsibility is delegated to the UI layer.
+- **Create & Edit Dialogs**: `create-site-form.tsx` wires `useCreateSite` and `useApproximateCoordinates` hook-level callbacks — errors use `scoped: true` (inline in dialog), site creation success uses `showBannerWithDelay` (`scoped: false`) after navigation fires. `edit-site-details-dialog.tsx` replaces 2 Sonner `toast.error` calls with `showBanner` (`scoped: true`) and routes mutation success/error through hook-level callbacks.
+- **Site Detail Pages**: Both `admin/sites/[id]/page.tsx` and `sites/[id]/page.tsx` wire `useRefreshSiteMetadata` with severity-aware banners (`scoped: false`): `warning` for partial refresh, `info` for already-complete, `success` for full refresh. The duplicated banner logic has been extracted into a shared `useRefreshMetadataWithBanner` hook.
+- **In-Page Copy Actions**: `site-information-card.tsx` and `site-measurements-api-card.tsx` replace clipboard `ReusableToast` calls with the shared `useClipboard` hook, adding async error handling for free.
+
+
+
+
+Files Updated (8)
+
+- `src/vertex/core/hooks/useSites.ts` [MODIFIED]
+- `src/vertex/components/features/sites/create-site-form.tsx` [MODIFIED]
+- `src/vertex/components/features/sites/edit-site-details-dialog.tsx` [MODIFIED]
+- `src/vertex/components/features/sites/site-information-card.tsx` [MODIFIED]
+- `src/vertex/components/features/sites/site-measurements-api-card.tsx` [MODIFIED]
+- `src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx` [MODIFIED]
+- `src/vertex/app/(authenticated)/sites/[id]/page.tsx` [MODIFIED]
+- `src/vertex/core/hooks/useRefreshMetadataWithBanner.ts` [ADDED]
+
+
+
+---
+
## Version 2.0.1
**Released:** June 09, 2026
diff --git a/src/vertex/components/features/sites/create-site-form.tsx b/src/vertex/components/features/sites/create-site-form.tsx
index d57f4c2938..eec22422ed 100644
--- a/src/vertex/components/features/sites/create-site-form.tsx
+++ b/src/vertex/components/features/sites/create-site-form.tsx
@@ -13,6 +13,9 @@ import ReusableButton from "@/components/shared/button/ReusableButton";
import { useAppSelector } from "@/core/redux/hooks";
import "leaflet/dist/leaflet.css";
import { useApproximateCoordinates, useCreateSite } from "@/core/hooks/useSites";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import { AqPlus } from "@airqo/icons-react";
import LocationAutocomplete from "@/components/features/location-autocomplete/LocationAutocomplete";
import { Label } from "@/components/ui/label";
@@ -68,8 +71,21 @@ export function CreateSiteForm({ disabled = false, basePath = "/admin/sites" }:
const router = useRouter();
const [inputMode, setInputMode] = useState<"siteName" | "coordinates">("coordinates");
const activeGroup = useAppSelector((state) => state.user.activeGroup);
- const { getApproximateCoordinates, isPending: isOptimizing } = useApproximateCoordinates();
- const { mutate: createSite, isPending: isCreating } = useCreateSite();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { getApproximateCoordinates, isPending: isOptimizing } = useApproximateCoordinates({
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Unable to get approximate coordinates: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
+ const { mutate: createSite, isPending: isCreating } = useCreateSite({
+ onSuccess: (_data, variables) => {
+ showBannerWithDelay({ severity: 'success', message: `Site '${variables.name}' created successfully`, scoped: false });
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Failed to create site: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
const { networks, isLoading: isLoadingNetworks } = useNetworks();
const form = useForm({
diff --git a/src/vertex/components/features/sites/edit-site-details-dialog.tsx b/src/vertex/components/features/sites/edit-site-details-dialog.tsx
index 9bbfa58f43..7f53024839 100644
--- a/src/vertex/components/features/sites/edit-site-details-dialog.tsx
+++ b/src/vertex/components/features/sites/edit-site-details-dialog.tsx
@@ -5,9 +5,11 @@ import { useForm } from "react-hook-form";
import * as z from "zod";
import { Form, FormField } from "@/components/ui/form";
import { useUpdateSiteDetails } from "@/core/hooks/useSites";
-import { toast } from "sonner";
import ReusableDialog from "@/components/shared/dialog/ReusableDialog";
import ReusableInputField from "@/components/shared/inputfield/ReusableInputField";
+import { useBanner } from "@/context/banner-context";
+import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
import { useEffect } from "react";
import { Site } from "@/app/types/sites";
@@ -49,7 +51,16 @@ export function EditSiteDetailsDialog({
site,
section,
}: EditSiteDetailsDialogProps) {
- const { mutate: updateSite, isPending } = useUpdateSiteDetails();
+ const { showBanner } = useBanner();
+ const { showBannerWithDelay } = useBannerWithDelay();
+ const { mutate: updateSite, isPending } = useUpdateSiteDetails({
+ onSuccess: () => {
+ showBannerWithDelay({ severity: 'success', message: 'Site details updated successfully', scoped: false });
+ },
+ onError: (error) => {
+ showBanner({ severity: 'error', message: `Failed to update site: ${getApiErrorMessage(error)}`, scoped: true });
+ },
+ });
const form = useForm({
resolver: zodResolver(siteFormSchema),
@@ -107,12 +118,12 @@ export function EditSiteDetailsDialog({
);
if (Object.keys(transformedData).length === 0) {
- toast.error("No fields have been modified");
+ showBanner({ severity: 'error', message: 'No fields have been modified', scoped: true });
return;
}
if (!site._id) {
- toast.error("Site ID is missing");
+ showBanner({ severity: 'error', message: 'Site ID is missing', scoped: true });
return;
}
diff --git a/src/vertex/components/features/sites/site-information-card.tsx b/src/vertex/components/features/sites/site-information-card.tsx
index 2aac3f3d5f..83c2485f4d 100644
--- a/src/vertex/components/features/sites/site-information-card.tsx
+++ b/src/vertex/components/features/sites/site-information-card.tsx
@@ -10,7 +10,7 @@ import {
} from "@/components/ui/tooltip";
import { AqEdit01, AqCopy01 } from "@airqo/icons-react";
import ReusableButton from "@/components/shared/button/ReusableButton";
-import ReusableToast from "@/components/shared/toast/ReusableToast";
+import { useClipboard } from "@/core/hooks/useClipboard";
import {
badgeColorClasses,
formatDisplayDate,
@@ -33,6 +33,7 @@ const DetailItem = ({ label, value }: { label: string; value: React.ReactNode })
);
export const SiteInformationCard: React.FC = ({ site, onEdit }) => {
+ const { handleCopy } = useClipboard();
const lastActiveCheck = site.lastActive
? formatDisplayDate(site.lastActive)
: null;
@@ -67,17 +68,7 @@ export const SiteInformationCard: React.FC = ({ site,
{site._id}
{
- if (site._id) {
- try {
- await navigator.clipboard.writeText(site._id);
- ReusableToast({ message: "Copied", type: "SUCCESS" });
- } catch (error) {
- console.error("Failed to copy:", error);
- ReusableToast({ message: "Failed to copy", type: "ERROR" });
- }
- }
- }}
+ onClick={() => site._id && handleCopy(site._id)}
className="p-1 h-auto hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
Icon={AqCopy01}
/>
diff --git a/src/vertex/components/features/sites/site-measurements-api-card.tsx b/src/vertex/components/features/sites/site-measurements-api-card.tsx
index afbb9befe1..b67c276667 100644
--- a/src/vertex/components/features/sites/site-measurements-api-card.tsx
+++ b/src/vertex/components/features/sites/site-measurements-api-card.tsx
@@ -1,14 +1,18 @@
+"use client";
+
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import React from "react";
-import ReusableToast from "@/components/shared/toast/ReusableToast";
+import { useClipboard } from "@/core/hooks/useClipboard";
interface SiteMeasurementsApiCardProps {
siteId: string;
}
const SiteMeasurementsApiCard: React.FC = ({ siteId }) => {
+ const { handleCopy } = useClipboard();
+
return (
Site Measurements API
@@ -22,11 +26,9 @@ const SiteMeasurementsApiCard: React.FC = ({ siteI
@@ -42,11 +44,9 @@ const SiteMeasurementsApiCard: React.FC = ({ siteI
diff --git a/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts
new file mode 100644
index 0000000000..7cd86b9a0d
--- /dev/null
+++ b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts
@@ -0,0 +1,34 @@
+import { useCallback } from "react";
+import { useRefreshSiteMetadata } from "@/core/hooks/useSites";
+import { useBanner } from "@/context/banner-context";
+import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage";
+import type { SiteRefreshResponse } from "@/core/apis/sites";
+import type { AxiosError } from "axios";
+
+interface ErrorResponse {
+ message: string;
+ errors?: {
+ message: string;
+ };
+}
+
+export const useRefreshMetadataWithBanner = () => {
+ const { showBanner } = useBanner();
+
+ const handleSuccess = useCallback((data: SiteRefreshResponse) => {
+ const msg = (data.message ?? "").toLowerCase();
+ if (msg.includes("partially refreshed")) {
+ showBanner({ severity: "warning", message: data.message ?? "Site metadata partially refreshed.", scoped: false });
+ } else if (msg.includes("already complete")) {
+ showBanner({ severity: "info", message: "Site metadata is already up to date.", scoped: false });
+ } else {
+ showBanner({ severity: "success", message: "Site metadata refreshed successfully.", scoped: false });
+ }
+ }, [showBanner]);
+
+ const handleError = useCallback((error: AxiosError) => {
+ showBanner({ severity: "error", message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false });
+ }, [showBanner]);
+
+ return useRefreshSiteMetadata({ onSuccess: handleSuccess, onError: handleError });
+};
diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts
index 7e07d3ebd2..98e3aa160c 100644
--- a/src/vertex/core/hooks/useSites.ts
+++ b/src/vertex/core/hooks/useSites.ts
@@ -1,6 +1,5 @@
import { useQuery, useMutation, useQueryClient, useInfiniteQuery, type QueryFunctionContext } from "@tanstack/react-query";
import {
- sites,
ApproximateCoordinatesResponse,
GetSitesSummaryParams,
SitesSummaryResponse,
@@ -9,14 +8,10 @@ import {
} from "../apis/sites";
import { adapter } from '../adapters';
import { DeviceActivitiesResponse } from "../apis/devices";
-
import { useGroupCohorts } from "./useCohorts";
import { useAppSelector } from "../redux/hooks";
-import { useUserContext } from "./useUserContext";
import { useMemo } from "react";
-import ReusableToast from "@/components/shared/toast/ReusableToast";
import { AxiosError } from "axios";
-import { getApiErrorMessage } from "../utils/getApiErrorMessage";
interface ErrorResponse {
message: string;
@@ -139,7 +134,12 @@ export const useSiteStatistics = (network?: string) => {
};
};
-export const useApproximateCoordinates = () => {
+interface UseApproximateCoordinatesOptions {
+ onSuccess?: (data: ApproximateCoordinatesResponse) => void;
+ onError?: (error: AxiosError) => void;
+}
+
+export const useApproximateCoordinates = (options?: UseApproximateCoordinatesOptions) => {
const {
mutate: getApproximateCoordinates,
data: approximateCoordinates,
@@ -152,11 +152,11 @@ export const useApproximateCoordinates = () => {
>({
mutationFn: ({ latitude, longitude }) =>
adapter.getApproximateCoordinates(latitude, longitude),
+ onSuccess: (data) => {
+ options?.onSuccess?.(data);
+ },
onError: (error) => {
- ReusableToast({
- message: `Unable to get approximate coordinates: ${getApiErrorMessage(error)}`,
- type: "ERROR",
- });
+ options?.onError?.(error);
},
});
@@ -188,7 +188,12 @@ export const useSiteDetails = (
});
};
-export const useUpdateSiteDetails = () => {
+interface UseUpdateSiteDetailsOptions {
+ onSuccess?: (siteId: string) => void;
+ onError?: (error: AxiosError) => void;
+}
+
+export const useUpdateSiteDetails = (options?: UseUpdateSiteDetailsOptions) => {
const queryClient = useQueryClient();
return useMutation<
@@ -203,19 +208,13 @@ export const useUpdateSiteDetails = () => {
return adapter.updateSiteDetails(siteId, cleanedData);
},
- onSuccess: (data, { siteId }) => {
- ReusableToast({
- message: "Site details updated successfully",
- type: "SUCCESS",
- });
+ onSuccess: (_data, { siteId }) => {
queryClient.invalidateQueries({ queryKey: ["site-details", siteId] });
queryClient.invalidateQueries({ queryKey: ["sites"] });
+ options?.onSuccess?.(siteId);
},
onError: (error) => {
- ReusableToast({
- message: `Failed to update site: ${getApiErrorMessage(error)}`,
- type: "ERROR",
- });
+ options?.onError?.(error);
},
});
};
@@ -228,7 +227,12 @@ interface CreateSiteRequest {
group: string;
}
-export const useCreateSite = () => {
+interface UseCreateSiteOptions {
+ onSuccess?: (data: CreateSiteResponse, variables: CreateSiteRequest) => void;
+ onError?: (error: AxiosError) => void;
+}
+
+export const useCreateSite = (options?: UseCreateSiteOptions) => {
const queryClient = useQueryClient();
const activeGroup = useAppSelector((state) => state.user.activeGroup);
@@ -251,55 +255,33 @@ export const useCreateSite = () => {
return createdSite;
},
onSuccess: (data, variables) => {
- ReusableToast({
- message: `Site '${variables.name}' created successfully`,
- type: "SUCCESS",
- });
queryClient.invalidateQueries({ queryKey: ["sites"] });
+ options?.onSuccess?.(data, variables);
},
onError: (error) => {
- ReusableToast({
- message: `Failed to create site: ${getApiErrorMessage(error)}`,
- type: "ERROR",
- });
+ options?.onError?.(error);
},
});
};
-export const useRefreshSiteMetadata = () => {
+interface UseRefreshSiteMetadataOptions {
+ onSuccess?: (data: SiteRefreshResponse, siteId: string) => void;
+ onError?: (error: AxiosError) => void;
+}
+
+export const useRefreshSiteMetadata = (options?: UseRefreshSiteMetadataOptions) => {
const queryClient = useQueryClient();
return useMutation, string>({
mutationFn: (siteId: string) => adapter.refreshSiteMetadata(siteId),
onSuccess: (data, siteId) => {
- // Update the cache with the newly enriched site data
queryClient.setQueryData(["site-details", siteId], data.site);
queryClient.invalidateQueries({ queryKey: ["sites"] });
queryClient.invalidateQueries({ queryKey: ["site-details", siteId] });
-
- const msg = (data.message ?? "").toLowerCase();
- if (msg.includes("partially refreshed")) {
- ReusableToast({
- message: data.message,
- type: "WARNING",
- });
- } else if (msg.includes("already complete")) {
- ReusableToast({
- message: "Site metadata is already up to date.",
- type: "INFO",
- });
- } else {
- ReusableToast({
- message: "Site metadata refreshed successfully.",
- type: "SUCCESS",
- });
- }
+ options?.onSuccess?.(data, siteId);
},
onError: (error) => {
- ReusableToast({
- message: `Refresh Failed: ${getApiErrorMessage(error)}`,
- type: "ERROR",
- });
+ options?.onError?.(error);
},
});
};