Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/vertex/app/(authenticated)/admin/sites/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/vertex/app/(authenticated)/sites/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/vertex/app/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary><strong>Changes (4)</strong></summary>

- **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.

</details>

<details>
<summary><strong>Files Updated (8)</strong></summary>

- `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]

</details>

---

## Version 2.0.1
**Released:** June 09, 2026

Expand Down
20 changes: 18 additions & 2 deletions src/vertex/components/features/sites/create-site-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
},
Comment on lines +78 to +87
});
const { networks, isLoading: isLoadingNetworks } = useNetworks();

const form = useForm<SiteFormValues>({
Expand Down
19 changes: 15 additions & 4 deletions src/vertex/components/features/sites/edit-site-details-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });
},
Comment on lines +58 to +62
});

const form = useForm<SiteFormValues>({
resolver: zodResolver(siteFormSchema),
Expand Down Expand Up @@ -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;
}

Expand Down
15 changes: 3 additions & 12 deletions src/vertex/components/features/sites/site-information-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,6 +33,7 @@ const DetailItem = ({ label, value }: { label: string; value: React.ReactNode })
);

export const SiteInformationCard: React.FC<SiteInformationCardProps> = ({ site, onEdit }) => {
const { handleCopy } = useClipboard();
const lastActiveCheck = site.lastActive
? formatDisplayDate(site.lastActive)
: null;
Expand Down Expand Up @@ -67,17 +68,7 @@ export const SiteInformationCard: React.FC<SiteInformationCardProps> = ({ site,
<span className="font-mono text-sm truncate max-w-[150px] sm:max-w-none">{site._id}</span>
<ReusableButton
variant="text"
onClick={async () => {
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}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines 3 to +7

interface SiteMeasurementsApiCardProps {
siteId: string;
}

const SiteMeasurementsApiCard: React.FC<SiteMeasurementsApiCardProps> = ({ siteId }) => {
const { handleCopy } = useClipboard();

return (
<Card className="w-full rounded-lg flex flex-col gap-4 px-3 py-2">
<h2 className="text-lg font-semibold mb-2">Site Measurements API</h2>
Expand All @@ -22,11 +26,9 @@ const SiteMeasurementsApiCard: React.FC<SiteMeasurementsApiCardProps> = ({ siteI
<Button
variant="ghost"
size="icon"
aria-label="Copy recent measurements API URL"
className="hover:bg-transparent"
Comment on lines 26 to 30
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`)}
>
<Copy className="w-4 h-4" />
</Button>
Comment on lines +31 to 34
Expand All @@ -42,11 +44,9 @@ const SiteMeasurementsApiCard: React.FC<SiteMeasurementsApiCardProps> = ({ siteI
<Button
variant="ghost"
size="icon"
aria-label="Copy historical measurements API URL"
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`)}
>
<Copy className="w-4 h-4" />
</Button>
Expand Down
27 changes: 27 additions & 0 deletions src/vertex/core/hooks/useRefreshMetadataWithBanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback } from "react";
import { useRefreshSiteMetadata } from "@/core/hooks/useSites";
import { useBanner } from "@/context/banner-context";
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Hooks can only be called from client components which use use client.

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 = 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]);
Comment on lines +18 to +27

const handleError = useCallback((error: AxiosError) => {
showBanner({ severity: "error", message: `Refresh Failed: ${getApiErrorMessage(error)}`, scoped: false });
}, [showBanner]);

return useRefreshSiteMetadata({ onSuccess: handleSuccess, onError: handleError });
};
Comment on lines +15 to +34
Loading
Loading