diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index f646e6b6c3..4154de352f 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -2,6 +2,48 @@ > **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend. +## Version 2.0.7 +**Released:** June 17, 2026 + +### Toast-to-Banner Migration & Clipboard Hook Consolidation + +Completed the migration of all remaining non-copy action notifications from floating `ReusableToast` calls to the context-aware `InfoBanner` system. Consolidated all clipboard copy logic under the shared `useClipboard` hook. + +
+BannerProvider Scope Fix (1) + +- **Provider Hierarchy Correction**: Moved `BannerProvider` above `AuthProvider` in `providers.tsx`. Previously, core auth components (`UserDataFetcher`, `AuthWrapper`) lived outside the `BannerProvider` boundary and could not call `useBanner`. The provider now wraps the full application tree, making banners available everywhere including auth-level components. + +
+ +
+Global Banner Migration — Auth & Core (3) + +- **Auth Provider (`authProvider.tsx`)**: Replaced all 4 `ReusableToast` calls with `showBanner({ scoped: false })` global banners. Affected messages: "Connection restored" (success), "Could not load user details" (error), "Your account has been deleted" (error), and "Your session has expired" (error ×2). These alerts now render in the `GlobalBannerContainer` regardless of the user's current page. +- **Recently Visited Hook (`useRecentlyVisited.ts`)**: Migrated the `localStorage` read failure toast ("Failed to load recently visited pages") to a global banner so it surfaces in the main layout instead of a floating overlay. +- **Clipboard Hook (`useClipboard.ts`)**: Updated the hook to use `toast.success` for successful copies (consistent with the intentional clipboard toast pattern) and `showBanner` for copy errors, aligning it with the dual-system convention. + +
+ +
+Scoped Banner Migration — Feature Components (2) + +- **Network Visibility Card (`network-visibility-card.tsx`)**: Replaced the "Failed to update network visibility" toast with a scoped banner (`scoped: true`). The error now renders inside the confirmation dialog via the existing `` in `ReusableDialog`, keeping the error contextually placed without requiring structural changes. +- **File Upload Parser (`FileUploadParser.tsx`)**: Replaced all 6 `ReusableToast` error calls with scoped banners. Added a `` inside the `BulkClaimColumnMapper` modal header and threaded `showBanner` down as a prop. Affected messages: invalid file format, file too large, CSV parse error, Excel read error, general import error, and no valid devices found. + +
+ +
+Clipboard Hook Consolidation (3) + +- **Cohort Detail Card (`cohort-detail-card.tsx`)**: Replaced inline `try/catch` clipboard logic with `useClipboard({ successMessage: 'Cohort ID copied to clipboard' })`. +- **Cohort Organizations Card (`cohort-organizations-card.tsx`)**: Removed the manual `handleCopyId` function and `ReusableToast` import. Now uses `useClipboard({ successMessage: 'Group ID copied to clipboard!' })`. +- **Networks Table (`client-paginated-networks-table.tsx`)**: Removed inline `useBanner` clipboard pattern. Now delegates to `useClipboard({ successMessage: 'Sensor Manufacturer ID copied' })` while preserving `event.stopPropagation()` at the call site. + +
+ +--- + ## Version 2.0.6 **Released:** June 14, 2026 diff --git a/src/vertex/app/providers.tsx b/src/vertex/app/providers.tsx index 383d25a7a3..c0cc309089 100644 --- a/src/vertex/app/providers.tsx +++ b/src/vertex/app/providers.tsx @@ -40,22 +40,22 @@ export default function Providers({ children, session }: { children: React.React } persistor={persistor}> - - - + + + {children} - - - {process.env.NODE_ENV !== "production" && ( - - )} - - + + {process.env.NODE_ENV !== "production" && ( + + )} + + + diff --git a/src/vertex/components/features/claim/FileUploadParser.tsx b/src/vertex/components/features/claim/FileUploadParser.tsx index a6cb358057..6521a69f3f 100644 --- a/src/vertex/components/features/claim/FileUploadParser.tsx +++ b/src/vertex/components/features/claim/FileUploadParser.tsx @@ -3,7 +3,7 @@ import React, { useRef, useState } from 'react'; import { AqUploadCloud02 } from '@airqo/icons-react'; import ReusableButton from '@/components/shared/button/ReusableButton'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner, BannerSlot } from '@/context/banner-context'; export interface ParsedFileData { headers: string[]; @@ -15,12 +15,14 @@ interface BulkClaimColumnMapperProps { filePreview: ParsedFileData; onConfirm: (deviceNameColumn: number, claimTokenColumn: number) => void; onCancel: () => void; + showBanner: ReturnType['showBanner']; } export const BulkClaimColumnMapper: React.FC = ({ filePreview, onConfirm, onCancel, + showBanner, }) => { const [deviceNameColumn, setDeviceNameColumn] = useState(() => { // Auto-detect device name column @@ -40,10 +42,7 @@ export const BulkClaimColumnMapper: React.FC = ({ const handleConfirm = () => { if (deviceNameColumn === claimTokenColumn) { - ReusableToast({ - message: 'Device name and claim token must be in different columns', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Device name and claim token must be in different columns', scoped: true }); return; } onConfirm(deviceNameColumn, claimTokenColumn); @@ -52,13 +51,16 @@ export const BulkClaimColumnMapper: React.FC = ({ return (
-
-

- Map Columns for Device Claiming -

-

- File: {filePreview.fileName} -

+
+
+

+ Map Columns for Device Claiming +

+

+ File: {filePreview.fileName} +

+
+
@@ -178,6 +180,7 @@ export const FileUploadParser: React.FC = ({ onFilesParse const [isImporting, setIsImporting] = useState(false); const [filePreview, setFilePreview] = useState(null); const fileInputRef = useRef(null); + const { showBanner } = useBanner(); const handleFileImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -185,16 +188,13 @@ export const FileUploadParser: React.FC = ({ onFilesParse const fileExtension = file.name.split('.').pop()?.toLowerCase(); if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { - ReusableToast({ - message: 'Invalid file format. Please upload a CSV or Excel file.', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true }); e.target.value = ''; return; } if (file.size > 5 * 1024 * 1024) { - ReusableToast({ message: 'File too large. Maximum size is 5MB.', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true }); e.target.value = ''; return; } @@ -221,10 +221,7 @@ export const FileUploadParser: React.FC = ({ onFilesParse setIsImporting(false); }, error: (error) => { - ReusableToast({ - message: `Error parsing CSV: ${error.message}`, - type: 'ERROR', - }); + showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true }); setIsImporting(false); }, }); @@ -247,16 +244,13 @@ export const FileUploadParser: React.FC = ({ onFilesParse setIsImporting(false); }; reader.onerror = () => { - ReusableToast({ message: 'Error reading Excel file', type: 'ERROR' }); + showBanner({ severity: 'error', message: 'Error reading Excel file', scoped: true }); setIsImporting(false); }; reader.readAsBinaryString(file); } } catch { - ReusableToast({ - message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', scoped: true }); setIsImporting(false); } @@ -285,10 +279,7 @@ export const FileUploadParser: React.FC = ({ onFilesParse .filter((device) => device.device_name.length > 0 && device.claim_token.length > 0); if (devices.length === 0) { - ReusableToast({ - message: 'No valid devices found in the selected columns', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'No valid devices found in the selected columns', scoped: true }); return; } @@ -307,6 +298,7 @@ export const FileUploadParser: React.FC = ({ onFilesParse filePreview={filePreview} onConfirm={handleConfirmImport} onCancel={handleCancelImport} + showBanner={showBanner} /> )} diff --git a/src/vertex/components/features/cohorts/cohort-detail-card.tsx b/src/vertex/components/features/cohorts/cohort-detail-card.tsx index 76a4d0ca87..e895f87a91 100644 --- a/src/vertex/components/features/cohorts/cohort-detail-card.tsx +++ b/src/vertex/components/features/cohorts/cohort-detail-card.tsx @@ -4,6 +4,7 @@ import ReusableButton from "@/components/shared/button/ReusableButton"; import { AqCopy01, AqEdit01 } from "@airqo/icons-react"; import { Switch } from "@/components/ui/switch"; import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; import { useEffect, useState } from "react"; @@ -44,6 +45,7 @@ const CohortDetailsCard: React.FC = ({ ); const { showBanner } = useBanner(); + const { handleCopy } = useClipboard({ successMessage: 'Cohort ID copied to clipboard' }); const { showBannerWithDelay } = useBannerWithDelay(); const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails(); const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate }); @@ -197,15 +199,7 @@ const CohortDetailsCard: React.FC = ({
{ - if (!id) return; - try { - await navigator.clipboard.writeText(id); - showBanner({ severity: 'success', message: 'Cohort ID copied to clipboard', scoped: false }); - } catch { - showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false }); - } - }} + onClick={() => { if (id) handleCopy(id); }} className="p-1" Icon={AqCopy01} /> diff --git a/src/vertex/components/features/cohorts/cohort-organizations-card.tsx b/src/vertex/components/features/cohorts/cohort-organizations-card.tsx index 4275b03187..654975922c 100644 --- a/src/vertex/components/features/cohorts/cohort-organizations-card.tsx +++ b/src/vertex/components/features/cohorts/cohort-organizations-card.tsx @@ -9,7 +9,7 @@ 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 { useClipboard } from "@/core/hooks/useClipboard"; import { AqCopy01 } from "@airqo/icons-react"; import { formatTitle } from "../org-picker/organization-picker"; @@ -25,7 +25,7 @@ export function CohortOrganizationsCard({ canUnassign = false, }: CohortOrganizationsCardProps) { const { groups: organizations, isLoading, refetch } = useGroupsByCohort(cohortId); - + const { handleCopy } = useClipboard({ successMessage: 'Group ID copied to clipboard!' }); const [selectedOrg, setSelectedOrg] = useState(null); const [showUnassignDialog, setShowUnassignDialog] = useState(false); const [showAllDialog, setShowAllDialog] = useState(false); @@ -41,10 +41,6 @@ export function CohortOrganizationsCard({ 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 })); @@ -68,7 +64,7 @@ export function CohortOrganizationsCard({ className="p-1 h-auto text-muted-foreground hover:text-primary" onClick={(e) => { e.stopPropagation(); - handleCopyId(item._id); + handleCopy(item._id); }} Icon={AqCopy01} aria-label="Copy Group ID" @@ -152,7 +148,7 @@ export function CohortOrganizationsCard({ handleCopyId(org._id)} + onClick={() => handleCopy(org._id)} className="p-1 h-auto text-muted-foreground hover:text-primary" Icon={AqCopy01} aria-label="Copy Organization ID" diff --git a/src/vertex/components/features/home/network-visibility-card.tsx b/src/vertex/components/features/home/network-visibility-card.tsx index 0dd90264d5..5f42750b3b 100644 --- a/src/vertex/components/features/home/network-visibility-card.tsx +++ b/src/vertex/components/features/home/network-visibility-card.tsx @@ -10,7 +10,7 @@ import { cohorts as cohortsApi } from '@/core/apis/cohorts'; import ReusableDialog from '@/components/shared/dialog/ReusableDialog'; import ReusableButton from '@/components/shared/button/ReusableButton'; import { PERMISSIONS } from '@/core/permissions/constants'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner } from '@/context/banner-context'; import { useQueryClient } from '@tanstack/react-query'; import { usePermissions } from '@/core/hooks/usePermissions'; import { cn } from '@/lib/utils'; @@ -30,6 +30,7 @@ const NetworkVisibilityCard = ({ onVisibilityChanged, showCoachMark }: NetworkVi const router = useRouter(); const { data: session } = useSession(); const user = useAppSelector(state => state.user.userDetails); + const { showBanner } = useBanner(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [targetVisibility, setTargetVisibility] = useState(false); @@ -129,10 +130,7 @@ const NetworkVisibilityCard = ({ onVisibilityChanged, showCoachMark }: NetworkVi } } catch (error) { console.error(error); - ReusableToast({ - message: 'Failed to update network visibility', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Failed to update network visibility', scoped: true }); } finally { setIsUpdating(false); setIsDialogOpen(false); diff --git a/src/vertex/components/features/networks/client-paginated-networks-table.tsx b/src/vertex/components/features/networks/client-paginated-networks-table.tsx index 9039b4c241..b0bccaf6b4 100644 --- a/src/vertex/components/features/networks/client-paginated-networks-table.tsx +++ b/src/vertex/components/features/networks/client-paginated-networks-table.tsx @@ -6,7 +6,7 @@ import ReusableTable, { import { useRouter } from "next/navigation"; import { Network } from "@/core/apis/networks"; import { AqCopy01, AqLinkExternal01 } from "@airqo/icons-react"; -import { useBanner } from "@/context/banner-context"; +import { useClipboard } from "@/core/hooks/useClipboard"; interface NetworksTableProps { networks: Network[]; @@ -26,7 +26,7 @@ export default function ClientPaginatedNetworksTable({ onNetworkClick, }: NetworksTableProps) { const router = useRouter(); - const { showBanner } = useBanner(); + const { handleCopy: copyToClipboard } = useClipboard({ successMessage: 'Sensor Manufacturer ID copied' }); const handleNetworkClick = (item: unknown) => { const network = item as Network; @@ -34,16 +34,9 @@ export default function ClientPaginatedNetworksTable({ else if (network._id) router.push(`/admin/networks/${network._id}`); }; - const handleCopy = async (text: string, event: React.MouseEvent) => { + const handleCopy = (text: string, event: React.MouseEvent) => { event.stopPropagation(); - if (text) { - try { - await navigator.clipboard.writeText(text); - showBanner({ message: "Sensor Manufacturer ID copied", severity: "success", scoped: false }); - } catch { - showBanner({ message: "Failed to copy ID", severity: "error", scoped: false }); - } - } + if (text) copyToClipboard(text); }; const networksWithId: TableNetwork[] = networks diff --git a/src/vertex/components/layout/secondary-sidebar.tsx b/src/vertex/components/layout/secondary-sidebar.tsx index b4c5193325..e4ee2c2079 100644 --- a/src/vertex/components/layout/secondary-sidebar.tsx +++ b/src/vertex/components/layout/secondary-sidebar.tsx @@ -98,21 +98,6 @@ const SecondarySidebar: React.FC = ({ overflow overflowType="auto" contentClassName={`flex flex-col h-full overflow-x-hidden scrollbar-thin ${styles.scrollbar}`} - footer={!isCollapsed && activeModule !== 'admin' && !isElectron && platform === 'win' && ( -
- - - - - Download for Windows - -
- )} > {/* Device Management Module - Personal devices for all users */} {activeModule === 'devices' && ( @@ -293,6 +278,22 @@ const SecondarySidebar: React.FC = ({ /> )} + + {!isCollapsed && activeModule !== 'admin' && !isElectron && platform === 'win' && ( +
+ + + + + Download for Windows + +
+ )}
diff --git a/src/vertex/core/auth/authProvider.tsx b/src/vertex/core/auth/authProvider.tsx index 37126e0e46..2d695e813a 100644 --- a/src/vertex/core/auth/authProvider.tsx +++ b/src/vertex/core/auth/authProvider.tsx @@ -19,7 +19,7 @@ import { } from '@/core/redux/slices/userSlice'; import { users } from '@/core/apis/users'; import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner } from '@/context/banner-context'; import SessionLoadingState from '@/components/layout/loading/session-loading'; import { getLastActiveGroupId, @@ -209,6 +209,7 @@ function UserDataFetcher({ children }: { children: React.ReactNode }) { const { data: session } = useSession(); const dispatch = useAppDispatch(); const isOnline = useOnlineStatus(); + const { showBanner } = useBanner(); const { activeGroup, isInitialized, @@ -263,12 +264,9 @@ function UserDataFetcher({ children }: { children: React.ReactNode }) { // Reset toast flag when back online if (isOnline && hasShownOfflineToastRef.current) { hasShownOfflineToastRef.current = false; - ReusableToast({ - message: 'Connection restored.', - type: 'SUCCESS', - }); + showBanner({ severity: 'success', message: 'Connection restored.', scoped: false }); } - }, [isOnline, cachedUser, fetchStatus]); + }, [isOnline, cachedUser, fetchStatus, showBanner]); // Handle errors (only real errors, not offline state) useEffect(() => { @@ -285,13 +283,10 @@ function UserDataFetcher({ children }: { children: React.ReactNode }) { if (cachedUser) { // ignore } else { - ReusableToast({ - message: `Could not load user details: ${getApiErrorMessage(error)}`, - type: 'ERROR', - }); + showBanner({ severity: 'error', message: `Could not load user details: ${getApiErrorMessage(error)}`, scoped: false }); } } - }, [isError, error, isOnline, cachedUser, fetchStatus]); + }, [isError, error, isOnline, cachedUser, fetchStatus, showBanner]); // Handle successful data fetching useEffect(() => { @@ -377,6 +372,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isLoggingOut = useAppSelector((state) => state.user.isLoggingOut); const logout = useLogout(); + const { showBanner } = useBanner(); const [hasHandledUnauthorized, setHasHandledUnauthorized] = useState(false); const [backendSessionAttempted, setBackendSessionAttempted] = useState(false); const hasStartedLogoutRef = useRef(false); @@ -446,10 +442,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { } logger.info('[AuthWrapper] Account deletion detected, logging out...'); - ReusableToast({ - message: 'Your account has been deleted. You have been logged out.', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Your account has been deleted. You have been logged out.', scoped: false }); logout(); return true; } @@ -610,10 +603,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { '[AuthWrapper] Multiple 401s with valid session - possible account deletion' ); setHasHandledUnauthorized(true); - ReusableToast({ - message: 'Your session has expired. Please log in again.', // more user-friendly message - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Your session has expired. Please log in again.', scoped: false }); logout(); return; } @@ -630,17 +620,14 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { // Session refresh failed - token expired logger.info('[AuthWrapper] Session expired, logging out'); setHasHandledUnauthorized(true); - ReusableToast({ - message: 'Your session has expired. Please log in again.', - type: 'ERROR', - }); + showBanner({ severity: 'error', message: 'Your session has expired. Please log in again.', scoped: false }); logout(); } catch (error) { logger.error('[AuthWrapper] Error handling unauthorized event', { error }); setHasHandledUnauthorized(true); logout(); } - }, [logout, update, isPublicRoute, hasHandledUnauthorized, isLoggingOut, checkAccountDeletionFlag]); + }, [logout, update, isPublicRoute, hasHandledUnauthorized, isLoggingOut, checkAccountDeletionFlag,showBanner]); // Listen for auth token expiration events useEffect(() => { diff --git a/src/vertex/core/hooks/useClipboard.ts b/src/vertex/core/hooks/useClipboard.ts index 82a1624e15..2a8f3fda95 100644 --- a/src/vertex/core/hooks/useClipboard.ts +++ b/src/vertex/core/hooks/useClipboard.ts @@ -1,4 +1,5 @@ import { useBanner } from '@/context/banner-context'; +import { toast } from '@/components/shared/toast/ReusableToast'; interface UseClipboardOptions { successMessage?: string; @@ -17,7 +18,7 @@ export const useClipboard = (options?: UseClipboardOptions) => { const handleCopy = async (text: string) => { try { await navigator.clipboard.writeText(text); - showBanner({ severity: 'success', message: successMessage, scoped }); + toast.success(successMessage); } catch { showBanner({ severity: 'error', message: errorMessage, scoped }); } diff --git a/src/vertex/core/hooks/useRecentlyVisited.ts b/src/vertex/core/hooks/useRecentlyVisited.ts index 2e54d5b349..93676120d1 100644 --- a/src/vertex/core/hooks/useRecentlyVisited.ts +++ b/src/vertex/core/hooks/useRecentlyVisited.ts @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useAppSelector } from '@/core/redux/hooks'; import { usePathname } from 'next/navigation'; import { ROUTE_LINKS } from '@/core/routes'; -import ReusableToast from '@/components/shared/toast/ReusableToast'; +import { useBanner } from '@/context/banner-context'; export interface RecentPage { label: string; @@ -45,6 +45,7 @@ export const useRecentlyVisited = () => { const [isLoaded, setIsLoaded] = useState(false); const userDetails = useAppSelector((state) => state.user.userDetails); const userId = userDetails?._id; + const { showBanner } = useBanner(); const storageKey = userId ? `${STORAGE_KEY}_${userId}` : null; @@ -63,11 +64,11 @@ export const useRecentlyVisited = () => { setVisitedPages([]); } } catch (error) { - ReusableToast({ message: "Failed to load recently visited pages", type: "ERROR" }); + showBanner({ severity: 'error', message: 'Failed to load recently visited pages', scoped: false }); } finally { setIsLoaded(true); } - }, [storageKey]); + }, [storageKey,showBanner]); // Update visited pages on pathname change useEffect(() => {