From 1178b5c744e39a081e7f3249a5ab02202f24ab51 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Tue, 9 Jun 2026 07:12:02 +0300 Subject: [PATCH 1/9] Update Site Management Module to use InfoBanners --- .../(authenticated)/admin/sites/[id]/page.tsx | 19 ++++- .../app/(authenticated)/sites/[id]/page.tsx | 19 ++++- .../features/sites/create-site-form.tsx | 20 ++++- .../sites/edit-site-details-dialog.tsx | 19 ++++- .../features/sites/site-information-card.tsx | 15 +--- .../sites/site-measurements-api-card.tsx | 14 ++-- src/vertex/core/hooks/useSites.ts | 84 ++++++++----------- 7 files changed, 112 insertions(+), 78 deletions(-) diff --git a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx index a2393823d3..4f8213ec99 100644 --- a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx +++ b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx @@ -5,6 +5,8 @@ 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 { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useParams } from "next/navigation"; @@ -29,7 +31,22 @@ 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 { showBanner } = useBanner(); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata({ + onSuccess: (data) => { + const msg = (data.message ?? "").toLowerCase(); + if (msg.includes("partially refreshed")) { + showBanner({ severity: 'warning', message: data.message, 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 }); + } + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }, + }); 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..e6da4b7144 100644 --- a/src/vertex/app/(authenticated)/sites/[id]/page.tsx +++ b/src/vertex/app/(authenticated)/sites/[id]/page.tsx @@ -5,6 +5,8 @@ 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 { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useParams } from "next/navigation"; @@ -31,7 +33,22 @@ 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 { showBanner } = useBanner(); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata({ + onSuccess: (data) => { + const msg = (data.message ?? "").toLowerCase(); + if (msg.includes("partially refreshed")) { + showBanner({ severity: 'warning', message: data.message, 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 }); + } + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }, + }); const router = useRouter(); const [editSection, setEditSection] = useState<"general" | "mobile" | null>( null 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..7b61f2229b 100644 --- a/src/vertex/components/features/sites/site-measurements-api-card.tsx +++ b/src/vertex/components/features/sites/site-measurements-api-card.tsx @@ -2,13 +2,15 @@ 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

@@ -23,10 +25,7 @@ const SiteMeasurementsApiCard: React.FC = ({ siteI variant="ghost" size="icon" className="hover:bg-transparent" - onClick={() => { - navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/recent?token=YOUR_TOKEN`); - ReusableToast({ message: "Copied", type: "SUCCESS" }); - }} + onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/recent?token=YOUR_TOKEN`)} > @@ -43,10 +42,7 @@ const SiteMeasurementsApiCard: React.FC = ({ siteI variant="ghost" size="icon" className="hover:bg-transparent" - onClick={() => { - navigator.clipboard.writeText(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/historical?token=YOUR_TOKEN`); - ReusableToast({ message: "Copied", type: "SUCCESS" }); - }} + onClick={() => handleCopy(`https://api.airqo.net/api/v2/devices/measurements/sites/${siteId}/historical?token=YOUR_TOKEN`)} > diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts index 7e07d3ebd2..804c64cb3a 100644 --- a/src/vertex/core/hooks/useSites.ts +++ b/src/vertex/core/hooks/useSites.ts @@ -14,7 +14,6 @@ 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"; @@ -139,7 +138,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 +156,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 +192,12 @@ export const useSiteDetails = ( }); }; -export const useUpdateSiteDetails = () => { +interface UseUpdateSiteDetailsOptions { + onSuccess?: () => void; + onError?: (error: AxiosError) => void; +} + +export const useUpdateSiteDetails = (options?: UseUpdateSiteDetailsOptions) => { const queryClient = useQueryClient(); return useMutation< @@ -203,19 +212,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?.(); }, onError: (error) => { - ReusableToast({ - message: `Failed to update site: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); }; @@ -228,7 +231,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 +259,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) => 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); }, onError: (error) => { - ReusableToast({ - message: `Refresh Failed: ${getApiErrorMessage(error)}`, - type: "ERROR", - }); + options?.onError?.(error); }, }); }; From 96f0006dcb0526664db2c201306e57a8a442712b Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Tue, 9 Jun 2026 07:20:05 +0300 Subject: [PATCH 2/9] update changelog.md --- src/vertex/app/changelog.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index beb8381637..b2874363af 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -4,6 +4,38 @@ --- +## Version 2.0.1 +**Released:** June 09, 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. +- **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 (7) + +- `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] + +
+ +--- + ## Version 2.0.0 **Released:** June 07, 2026 From 8b9c592c6b87496fe3bb686476f72ba46b258eaa Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Tue, 9 Jun 2026 08:36:26 +0300 Subject: [PATCH 3/9] Refactor the useRefreshMetadataWithBanner logic --- .../(authenticated)/admin/sites/[id]/page.tsx | 22 +++------------- .../app/(authenticated)/sites/[id]/page.tsx | 22 +++------------- src/vertex/app/changelog.md | 5 ++-- .../hooks/useRefreshMetadataWithBanner.ts | 26 +++++++++++++++++++ 4 files changed, 35 insertions(+), 40 deletions(-) create mode 100644 src/vertex/core/hooks/useRefreshMetadataWithBanner.ts diff --git a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx index 4f8213ec99..3ec52f600e 100644 --- a/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx +++ b/src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx @@ -4,9 +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 { useBanner } from "@/context/banner-context"; -import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +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,22 +30,7 @@ export default function SiteDetailsPage() { const params = useParams(); const siteId = params.id as string; const { data: site, isLoading, error } = useSiteDetails(siteId); - const { showBanner } = useBanner(); - const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata({ - onSuccess: (data) => { - const msg = (data.message ?? "").toLowerCase(); - if (msg.includes("partially refreshed")) { - showBanner({ severity: 'warning', message: data.message, 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 }); - } - }, - onError: (error) => { - showBanner({ severity: 'error', message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false }); - }, - }); + 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 e6da4b7144..43147b6408 100644 --- a/src/vertex/app/(authenticated)/sites/[id]/page.tsx +++ b/src/vertex/app/(authenticated)/sites/[id]/page.tsx @@ -4,9 +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 { useBanner } from "@/context/banner-context"; -import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +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"; @@ -33,22 +32,7 @@ export default function UserSiteDetailsPage() { const params = useParams(); const siteId = params.id as string; const { data: site, isLoading, error } = useSiteDetails(siteId); - const { showBanner } = useBanner(); - const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata({ - onSuccess: (data) => { - const msg = (data.message ?? "").toLowerCase(); - if (msg.includes("partially refreshed")) { - showBanner({ severity: 'warning', message: data.message, 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 }); - } - }, - onError: (error) => { - showBanner({ severity: 'error', message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false }); - }, - }); + 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 b2874363af..8b0d126b2e 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -12,11 +12,11 @@ Migrated all user-facing notifications in the Site Management module from `ReusableToast` and Sonner `toast` to the centralized `useBanner` / `useBannerWithDelay` / `useClipboard` system.
-Changes (4) +Changes (5) - **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. +- **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.
@@ -31,6 +31,7 @@ Migrated all user-facing notifications in the Site Management module from `Reusa - `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] diff --git a/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts new file mode 100644 index 0000000000..e26c01946b --- /dev/null +++ b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts @@ -0,0 +1,26 @@ +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"; + +export const useRefreshMetadataWithBanner = () => { + const { showBanner } = useBanner(); + + const handleSuccess = (data: SiteRefreshResponse) => { + const msg = (data.message ?? "").toLowerCase(); + if (msg.includes("partially refreshed")) { + showBanner({ severity: "warning", message: data.message, 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 }); + } + }; + + const handleError = (error: AxiosError) => { + showBanner({ severity: "error", message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }; + + return useRefreshSiteMetadata({ onSuccess: handleSuccess, onError: handleError }); +}; From 81c8f1df15866454a3c07a6af7f4dfa8a6549557 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Tue, 9 Jun 2026 20:27:48 +0300 Subject: [PATCH 4/9] Edit changelogmd and delete unused variables --- src/vertex/app/changelog.md | 4 ++-- .../components/features/sites/site-measurements-api-card.tsx | 2 ++ src/vertex/core/hooks/useSites.ts | 4 ---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 8b0d126b2e..1b1f4d72c1 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -12,7 +12,7 @@ Migrated all user-facing notifications in the Site Management module from `ReusableToast` and Sonner `toast` to the centralized `useBanner` / `useBannerWithDelay` / `useClipboard` system.
-Changes (5) +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. @@ -22,7 +22,7 @@ Migrated all user-facing notifications in the Site Management module from `Reusa
-Files Updated (7) +Files Updated (8) - `src/vertex/core/hooks/useSites.ts` [MODIFIED] - `src/vertex/components/features/sites/create-site-form.tsx` [MODIFIED] 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 7b61f2229b..709b983597 100644 --- a/src/vertex/components/features/sites/site-measurements-api-card.tsx +++ b/src/vertex/components/features/sites/site-measurements-api-card.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Copy } from "lucide-react"; diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts index 804c64cb3a..4ae7b590a9 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,13 +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 { AxiosError } from "axios"; -import { getApiErrorMessage } from "../utils/getApiErrorMessage"; interface ErrorResponse { message: string; From 4e6412b5d0954622987014b0269349a502137cdc Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Tue, 9 Jun 2026 20:59:48 +0300 Subject: [PATCH 5/9] Handle undefined msg from the backend for partial cahe updates --- .../hooks/useRefreshMetadataWithBanner.ts | 2 +- src/vertex/core/hooks/useSites.ts | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts index e26c01946b..4224199cf0 100644 --- a/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts +++ b/src/vertex/core/hooks/useRefreshMetadataWithBanner.ts @@ -10,7 +10,7 @@ export const useRefreshMetadataWithBanner = () => { const handleSuccess = (data: SiteRefreshResponse) => { const msg = (data.message ?? "").toLowerCase(); if (msg.includes("partially refreshed")) { - showBanner({ severity: "warning", message: data.message, scoped: false }); + 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 { diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts index 4ae7b590a9..0e7d2d190c 100644 --- a/src/vertex/core/hooks/useSites.ts +++ b/src/vertex/core/hooks/useSites.ts @@ -136,7 +136,7 @@ export const useSiteStatistics = (network?: string) => { interface UseApproximateCoordinatesOptions { onSuccess?: (data: ApproximateCoordinatesResponse) => void; - onError?: (error: AxiosError) => void; + onError?: (error: AxiosError) => void; } export const useApproximateCoordinates = (options?: UseApproximateCoordinatesOptions) => { @@ -153,10 +153,10 @@ export const useApproximateCoordinates = (options?: UseApproximateCoordinatesOpt mutationFn: ({ latitude, longitude }) => adapter.getApproximateCoordinates(latitude, longitude), onSuccess: (data) => { - options?.onSuccess?.(data); + try { options?.onSuccess?.(data); } catch (e) { console.error(e); } }, onError: (error) => { - options?.onError?.(error); + try { options?.onError?.(error); } catch (e) { console.error(e); } }, }); @@ -190,7 +190,7 @@ export const useSiteDetails = ( interface UseUpdateSiteDetailsOptions { onSuccess?: () => void; - onError?: (error: AxiosError) => void; + onError?: (error: AxiosError) => void; } export const useUpdateSiteDetails = (options?: UseUpdateSiteDetailsOptions) => { @@ -211,10 +211,10 @@ export const useUpdateSiteDetails = (options?: UseUpdateSiteDetailsOptions) => { onSuccess: (_data, { siteId }) => { queryClient.invalidateQueries({ queryKey: ["site-details", siteId] }); queryClient.invalidateQueries({ queryKey: ["sites"] }); - options?.onSuccess?.(); + try { options?.onSuccess?.(); } catch (e) { console.error(e); } }, onError: (error) => { - options?.onError?.(error); + try { options?.onError?.(error); } catch (e) { console.error(e); } }, }); }; @@ -229,7 +229,7 @@ interface CreateSiteRequest { interface UseCreateSiteOptions { onSuccess?: (data: CreateSiteResponse, variables: CreateSiteRequest) => void; - onError?: (error: AxiosError) => void; + onError?: (error: AxiosError) => void; } export const useCreateSite = (options?: UseCreateSiteOptions) => { @@ -256,17 +256,17 @@ export const useCreateSite = (options?: UseCreateSiteOptions) => { }, onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ["sites"] }); - options?.onSuccess?.(data, variables); + try { options?.onSuccess?.(data, variables); } catch (e) { console.error(e); } }, onError: (error) => { - options?.onError?.(error); + try { options?.onError?.(error); } catch (e) { console.error(e); } }, }); }; interface UseRefreshSiteMetadataOptions { onSuccess?: (data: SiteRefreshResponse) => void; - onError?: (error: AxiosError) => void; + onError?: (error: AxiosError) => void; } export const useRefreshSiteMetadata = (options?: UseRefreshSiteMetadataOptions) => { @@ -278,10 +278,10 @@ export const useRefreshSiteMetadata = (options?: UseRefreshSiteMetadataOptions) queryClient.setQueryData(["site-details", siteId], data.site); queryClient.invalidateQueries({ queryKey: ["sites"] }); queryClient.invalidateQueries({ queryKey: ["site-details", siteId] }); - options?.onSuccess?.(data); + try { options?.onSuccess?.(data); } catch (e) { console.error(e); } }, onError: (error) => { - options?.onError?.(error); + try { options?.onError?.(error); } catch (e) { console.error(e); } }, }); }; From 260d1131132b7065def57b6faaa9bf7444a2b0b9 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 10 Jun 2026 06:47:43 +0300 Subject: [PATCH 6/9] Remove the try catch block because it's an overkill --- src/vertex/core/hooks/useSites.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts index 0e7d2d190c..0da24d66b6 100644 --- a/src/vertex/core/hooks/useSites.ts +++ b/src/vertex/core/hooks/useSites.ts @@ -153,10 +153,10 @@ export const useApproximateCoordinates = (options?: UseApproximateCoordinatesOpt mutationFn: ({ latitude, longitude }) => adapter.getApproximateCoordinates(latitude, longitude), onSuccess: (data) => { - try { options?.onSuccess?.(data); } catch (e) { console.error(e); } + options?.onSuccess?.(data); }, onError: (error) => { - try { options?.onError?.(error); } catch (e) { console.error(e); } + options?.onError?.(error); }, }); @@ -211,10 +211,10 @@ export const useUpdateSiteDetails = (options?: UseUpdateSiteDetailsOptions) => { onSuccess: (_data, { siteId }) => { queryClient.invalidateQueries({ queryKey: ["site-details", siteId] }); queryClient.invalidateQueries({ queryKey: ["sites"] }); - try { options?.onSuccess?.(); } catch (e) { console.error(e); } + options?.onSuccess?.(); }, onError: (error) => { - try { options?.onError?.(error); } catch (e) { console.error(e); } + options?.onError?.(error); }, }); }; @@ -256,10 +256,10 @@ export const useCreateSite = (options?: UseCreateSiteOptions) => { }, onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ["sites"] }); - try { options?.onSuccess?.(data, variables); } catch (e) { console.error(e); } + options?.onSuccess?.(data, variables); }, onError: (error) => { - try { options?.onError?.(error); } catch (e) { console.error(e); } + options?.onError?.(error); }, }); }; @@ -278,10 +278,10 @@ export const useRefreshSiteMetadata = (options?: UseRefreshSiteMetadataOptions) queryClient.setQueryData(["site-details", siteId], data.site); queryClient.invalidateQueries({ queryKey: ["sites"] }); queryClient.invalidateQueries({ queryKey: ["site-details", siteId] }); - try { options?.onSuccess?.(data); } catch (e) { console.error(e); } + options?.onSuccess?.(data); }, onError: (error) => { - try { options?.onError?.(error); } catch (e) { console.error(e); } + options?.onError?.(error); }, }); }; From 7c7342434246ca9f62f89997a5b34ef3d8bcc555 Mon Sep 17 00:00:00 2001 From: BwanikaRobert Date: Wed, 10 Jun 2026 06:48:16 +0300 Subject: [PATCH 7/9] Add aria-label to the copy buttons are better screen readers --- .../components/features/sites/site-measurements-api-card.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 709b983597..b67c276667 100644 --- a/src/vertex/components/features/sites/site-measurements-api-card.tsx +++ b/src/vertex/components/features/sites/site-measurements-api-card.tsx @@ -26,6 +26,7 @@ const SiteMeasurementsApiCard: React.FC = ({ siteI