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); }, }); };