diff --git a/package-lock.json b/package-lock.json
index 1fd70b3..14e542c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,10 +9,12 @@
"version": "0.0.0",
"license": "MIT-0",
"dependencies": {
+ "@mswjs/interceptors": "^0.41.9",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
"date-fns": "^4.1.0",
+ "graphql": "^16.13.2",
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-copy-to-clipboard": "^5.1.1",
@@ -20,6 +22,7 @@
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1",
"tailwindcss": "^4.2.0",
+ "type-fest": "^5.6.0",
"undici": "^8.1.0"
},
"devDependencies": {
@@ -40,7 +43,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.0.1",
- "msw": "^2.12.14",
+ "msw": "^2.14.6",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
@@ -1422,10 +1425,9 @@
}
},
"node_modules/@mswjs/interceptors": {
- "version": "0.41.6",
- "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.6.tgz",
- "integrity": "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==",
- "dev": true,
+ "version": "0.41.9",
+ "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz",
+ "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==",
"license": "MIT",
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
@@ -1443,7 +1445,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
- "dev": true,
"license": "MIT"
},
"node_modules/@open-draft/deferred-promise": {
@@ -1457,7 +1458,6 @@
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"is-node-process": "^1.2.0",
@@ -1468,7 +1468,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
- "dev": true,
"license": "MIT"
},
"node_modules/@playwright/test": {
@@ -4062,7 +4061,6 @@
"version": "16.13.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
"integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==",
- "dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
@@ -4255,7 +4253,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
- "dev": true,
"license": "MIT"
},
"node_modules/is-potential-custom-element-name": {
@@ -5022,9 +5019,9 @@
"license": "MIT"
},
"node_modules/msw": {
- "version": "2.13.6",
- "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.6.tgz",
- "integrity": "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==",
+ "version": "2.14.6",
+ "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz",
+ "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -5040,7 +5037,7 @@
"outvariant": "^1.4.3",
"path-to-regexp": "^6.3.0",
"picocolors": "^1.1.1",
- "rettime": "^0.11.7",
+ "rettime": "^0.11.11",
"statuses": "^2.0.2",
"strict-event-emitter": "^0.5.1",
"tough-cookie": "^6.0.1",
@@ -5150,7 +5147,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
- "dev": true,
"license": "MIT"
},
"node_modules/p-limit": {
@@ -5554,9 +5550,9 @@
}
},
"node_modules/rettime": {
- "version": "0.11.8",
- "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz",
- "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==",
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz",
+ "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==",
"dev": true,
"license": "MIT"
},
@@ -5753,7 +5749,6 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/string-width": {
@@ -5834,7 +5829,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
@@ -5987,7 +5981,6 @@
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
- "dev": true,
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
diff --git a/package.json b/package.json
index 52a18d1..ee7a525 100644
--- a/package.json
+++ b/package.json
@@ -16,10 +16,12 @@
"e2e": "playwright test"
},
"dependencies": {
+ "@mswjs/interceptors": "^0.41.9",
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.100.14",
"axios": "^1.16.1",
"date-fns": "^4.1.0",
+ "graphql": "^16.13.2",
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-copy-to-clipboard": "^5.1.1",
@@ -27,6 +29,7 @@
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1",
"tailwindcss": "^4.2.0",
+ "type-fest": "^5.6.0",
"undici": "^8.1.0"
},
"devDependencies": {
@@ -47,7 +50,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^29.0.1",
- "msw": "^2.12.14",
+ "msw": "^2.14.6",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
index 80f1930..33dde9e 100644
--- a/public/mockServiceWorker.js
+++ b/public/mockServiceWorker.js
@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
-const PACKAGE_VERSION = '2.13.6'
+const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
diff --git a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx
index a5b70ed..f97ddf5 100644
--- a/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx
+++ b/src/components/adoption/ApproveRejectButtons/ApproveRejectButtons.test.tsx
@@ -27,15 +27,17 @@ describe('ApproveRejectButtons', () => {
vi.clearAllMocks();
mockUseRoleGuard.mockReturnValue({
- role: 'admin',
+ role: 'ADMIN',
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
mockUseAdoptionApprovals.mockReturnValue({
hasDecided: false,
- requiredRoles: ['admin'],
+ requiredRoles: ['ADMIN'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: false,
});
@@ -48,7 +50,7 @@ describe('ApproveRejectButtons', () => {
it('does NOT render when user already decided', () => {
mockUseAdoptionApprovals.mockReturnValue({
hasDecided: true,
- requiredRoles: ['admin'],
+ requiredRoles: ['ADMIN'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: false,
});
@@ -59,9 +61,11 @@ describe('ApproveRejectButtons', () => {
it('does NOT render when user role not in requiredRoles', () => {
mockUseRoleGuard.mockReturnValue({
- role: 'user',
+ role: 'USER',
isAdmin: false,
+ isShelter: false,
isUser: true,
+ canApprove: false,
hasAccess: vi.fn().mockReturnValue(false),
});
@@ -118,7 +122,7 @@ describe('ApproveRejectButtons', () => {
it('Buttons disabled during loading and Spinner visible when isPending === true', () => {
mockUseAdoptionApprovals.mockReturnValue({
hasDecided: false,
- requiredRoles: ['admin'],
+ requiredRoles: ['ADMIN'],
mutateApprovalDecision: mockMutateApprovalDecision,
isPending: true,
});
diff --git a/src/components/badges/PendingApprovalBadge.tsx b/src/components/badges/PendingApprovalBadge.tsx
new file mode 100644
index 0000000..e9c727e
--- /dev/null
+++ b/src/components/badges/PendingApprovalBadge.tsx
@@ -0,0 +1,25 @@
+import { usePendingApprovalsCount } from "../../hooks/usePendingApprovalsCount";
+import { useRoleGuard } from "../../hooks/useRoleGuard";
+
+interface PendingApprovalBadgeProps {
+ className?: string;
+}
+
+export function PendingApprovalBadge({ className = "" }: PendingApprovalBadgeProps) {
+ const { canApprove } = useRoleGuard();
+ const { count, displayCount } = usePendingApprovalsCount();
+
+ if (!canApprove || count === 0) {
+ return null;
+ }
+
+ return (
+
+ {displayCount}
+
+ );
+}
diff --git a/src/components/badges/__tests__/PendingApprovalBadge.test.tsx b/src/components/badges/__tests__/PendingApprovalBadge.test.tsx
new file mode 100644
index 0000000..6aa9f6e
--- /dev/null
+++ b/src/components/badges/__tests__/PendingApprovalBadge.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { PendingApprovalBadge } from "../PendingApprovalBadge";
+
+const mockUsePendingApprovalsCount = vi.fn();
+const mockUseRoleGuard = vi.fn();
+
+vi.mock("../../../hooks/usePendingApprovalsCount", () => ({
+ usePendingApprovalsCount: () => mockUsePendingApprovalsCount(),
+}));
+
+vi.mock("../../../hooks/useRoleGuard", () => ({
+ useRoleGuard: () => mockUseRoleGuard(),
+}));
+
+describe("PendingApprovalBadge", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseRoleGuard.mockReturnValue({
+ role: "ADMIN",
+ isAdmin: true,
+ isShelter: false,
+ isUser: false,
+ canApprove: true,
+ hasAccess: vi.fn().mockReturnValue(true),
+ });
+ });
+
+ it("renders the exact count when it is below 10", () => {
+ mockUsePendingApprovalsCount.mockReturnValue({
+ count: 4,
+ displayCount: "4",
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId("pending-approval-badge")).toHaveTextContent("4");
+ });
+
+ it('caps the visible label at "9+" above 9', () => {
+ mockUsePendingApprovalsCount.mockReturnValue({
+ count: 12,
+ displayCount: "9+",
+ isLoading: false,
+ isError: false,
+ });
+
+ render();
+
+ expect(screen.getByTestId("pending-approval-badge")).toHaveTextContent("9+");
+ });
+
+ it("hides the badge for non-approval roles", () => {
+ mockUseRoleGuard.mockReturnValue({
+ role: "USER",
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
+
+ mockUsePendingApprovalsCount.mockReturnValue({
+ count: 6,
+ displayCount: "6",
+ isLoading: false,
+ isError: false,
+ });
+
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("hides the badge when the count is zero", () => {
+ mockUsePendingApprovalsCount.mockReturnValue({
+ count: 0,
+ displayCount: "0",
+ isLoading: false,
+ isError: false,
+ });
+
+ const { container } = render();
+
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx
index 2b497d1..44fc8cc 100644
--- a/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx
+++ b/src/components/escrow/__tests__/CompleteAdoptionButton.test.tsx
@@ -23,9 +23,11 @@ beforeEach(() => {
// must include all fields useRoleGuard returns: role, isAdmin, isUser
vi.mocked(useRoleGuard).mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
@@ -47,9 +49,11 @@ const defaultProps = {
describe("CompleteAdoptionButton", () => {
it("is hidden for non-admins", () => {
vi.mocked(useRoleGuard).mockReturnValue({
- role: "user",
+ role: "USER",
isAdmin: false,
+ isShelter: false,
isUser: true,
+ canApprove: false,
hasAccess: vi.fn().mockReturnValue(false),
});
const { container } = render();
diff --git a/src/components/layout/ApprovalBanner.tsx b/src/components/layout/ApprovalBanner.tsx
index d9e69e9..055552f 100644
--- a/src/components/layout/ApprovalBanner.tsx
+++ b/src/components/layout/ApprovalBanner.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { usePendingApprovalsCount } from "../../hooks/usePendingApprovalsCount";
+import { useRoleGuard } from "../../hooks/useRoleGuard";
import {
setBannerDismissed,
shouldShowBanner,
@@ -8,18 +9,21 @@ import { Link } from "react-router-dom";
export default function ApprovalBanner() {
const { count, isLoading } = usePendingApprovalsCount();
+ const { canApprove } = useRoleGuard();
const [visible, setVisible] = useState(false);
- const role = localStorage.getItem("role");
- const allowed = role === "ADMIN" || role === "SHELTER";
-
useEffect(() => {
- if (!allowed || isLoading) return;
+ if (!canApprove || isLoading) return;
+
+ if (count === 0) {
+ setVisible(false);
+ return;
+ }
if (count > 0 && shouldShowBanner(count)) {
setVisible(true);
}
- }, [count, isLoading, allowed]);
+ }, [count, isLoading, canApprove]);
if (!visible) return null;
@@ -43,4 +47,4 @@ export default function ApprovalBanner() {
);
-}
\ No newline at end of file
+}
diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx
index 23ea757..7acdd33 100644
--- a/src/components/layout/Navbar.tsx
+++ b/src/components/layout/Navbar.tsx
@@ -1,17 +1,41 @@
import { Link, useLocation } from "react-router-dom";
-import { House, Eye, List, Heart, ChevronDown } from "lucide-react";
+import { House, Eye, List, Heart, ChevronDown, ClipboardList } from "lucide-react";
import logo from "../../assets/logo.svg";
import owner from "../../assets/owner.png";
+import { PendingApprovalBadge } from "../badges/PendingApprovalBadge";
+import { useRoleGuard } from "../../hooks/useRoleGuard";
import { NotificationCentreDropdown } from "../notifications";
-const navLinks = [
+const baseNavLinks = [
{ label: "Home", path: "/home", icon: House },
{ label: "Interests", path: "/interests", icon: Eye },
{ label: "Listings", path: "/listings", icon: List },
];
+type NavLink = {
+ label: string;
+ path: string;
+ icon: typeof House;
+ hasBadge?: boolean;
+};
+
export function Navbar() {
const location = useLocation();
+ const { canApprove, isAdmin } = useRoleGuard();
+
+ const navLinks: NavLink[] = [
+ ...baseNavLinks,
+ ...(canApprove
+ ? [
+ {
+ label: "Approvals",
+ path: isAdmin ? "/admin/approvals" : "/shelter/approvals",
+ icon: ClipboardList,
+ hasBadge: true,
+ },
+ ]
+ : []),
+ ];
return (
);
-}
\ No newline at end of file
+}
diff --git a/src/components/listings/__tests__/SettlementFailureState.test.tsx b/src/components/listings/__tests__/SettlementFailureState.test.tsx
index bf4ef08..c71134b 100644
--- a/src/components/listings/__tests__/SettlementFailureState.test.tsx
+++ b/src/components/listings/__tests__/SettlementFailureState.test.tsx
@@ -54,9 +54,11 @@ describe("SettlementFailureState", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseRoleGuard.mockReturnValue({
- role: "user",
+ role: "USER",
isAdmin: false,
+ isShelter: false,
isUser: true,
+ canApprove: false,
hasAccess: vi.fn().mockReturnValue(false),
});
mockRetrySettlement.mockResolvedValue(undefined);
@@ -80,9 +82,11 @@ describe("SettlementFailureState", () => {
it("hides the retry button for non-admin users", () => {
mockUseRoleGuard.mockReturnValue({
- role: "user",
+ role: "USER",
isAdmin: false,
+ isShelter: false,
isUser: true,
+ canApprove: false,
hasAccess: vi.fn().mockReturnValue(false),
});
renderComponent();
@@ -91,9 +95,11 @@ describe("SettlementFailureState", () => {
it("shows the retry button for admin users", () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
renderComponent();
@@ -102,9 +108,11 @@ describe("SettlementFailureState", () => {
it("shows the confirmation modal when admin clicks retry, before mutation fires", () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
@@ -121,9 +129,11 @@ describe("SettlementFailureState", () => {
it("closes the confirmation modal when cancel is clicked without calling mutation", () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
@@ -140,9 +150,11 @@ describe("SettlementFailureState", () => {
it("calls retrySettlement with the correct escrowId after confirming", async () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
@@ -158,9 +170,11 @@ describe("SettlementFailureState", () => {
it("shows a spinner on the confirm button while the retry is in progress", async () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
@@ -189,9 +203,11 @@ describe("SettlementFailureState", () => {
it("shows a success toast after a successful retry", async () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
mockRetrySettlement.mockResolvedValue(undefined);
@@ -212,9 +228,11 @@ describe("SettlementFailureState", () => {
it("shows an error toast when the retry fails", async () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
mockRetrySettlement.mockRejectedValue(
@@ -237,9 +255,11 @@ describe("SettlementFailureState", () => {
it("calls the onRetry prop after a successful retry", async () => {
mockUseRoleGuard.mockReturnValue({
- role: "admin",
+ role: "ADMIN",
isAdmin: true,
+ isShelter: false,
isUser: false,
+ canApprove: true,
hasAccess: vi.fn().mockReturnValue(true),
});
mockRetrySettlement.mockResolvedValue(undefined);
diff --git a/src/hooks/usePendingApprovalsCount.ts b/src/hooks/usePendingApprovalsCount.ts
index f88a157..4fb2aed 100644
--- a/src/hooks/usePendingApprovalsCount.ts
+++ b/src/hooks/usePendingApprovalsCount.ts
@@ -1,16 +1,38 @@
-import { useEffect, useState } from "react";
+import { useApiQuery } from "./useApiQuery";
+import { useRoleGuard } from "./useRoleGuard";
+import { apiClient } from "../lib/api-client";
-export const usePendingApprovalsCount = () => {
- const [count, setCount] = useState(0);
- const [isLoading, setLoading] = useState(true);
+const POLL_INTERVAL_MS = 300_000;
+const MAX_DISPLAY = 9;
- useEffect(() => {
- fetch("/shelter/approvals?status=PENDING&limit=0")
- .then((res) => res.json())
- .then((data) => setCount(data?.count || 0))
- .catch(() => setCount(0))
- .finally(() => setLoading(false));
- }, []);
+interface PendingApprovalsResponse {
+ count?: number;
+ total?: number;
+}
- return { count, isLoading };
-};
\ No newline at end of file
+export function usePendingApprovalsCount() {
+ const { canApprove } = useRoleGuard();
+
+ const query = useApiQuery(
+ ["pending-approvals-count"],
+ () =>
+ apiClient.get(
+ "/shelter/approvals?status=PENDING&limit=0",
+ ),
+ {
+ enabled: canApprove,
+ refetchInterval: POLL_INTERVAL_MS,
+ refetchIntervalInBackground: true,
+ staleTime: 0,
+ },
+ );
+
+ const count = query.data?.count ?? query.data?.total ?? 0;
+
+ return {
+ count,
+ displayCount: count > MAX_DISPLAY ? `${MAX_DISPLAY}+` : String(count),
+ isLoading: query.isLoading,
+ isError: query.isError,
+ };
+}
diff --git a/src/hooks/useRoleGuard.ts b/src/hooks/useRoleGuard.ts
index e244a15..b3c98d8 100644
--- a/src/hooks/useRoleGuard.ts
+++ b/src/hooks/useRoleGuard.ts
@@ -1,27 +1,32 @@
+import type { UserRole } from "../types/auth";
+
/**
* useRoleGuard
*
* Reads the current user's role and exposes convenience booleans.
- * Role is stored under the "petad_user_role" key in localStorage so it can be
- * swapped for a React context / auth provider once one is wired up.
- *
- * Supported role values: "admin" | "user"
+ * We normalize the stored value so older lowercase role flags and the current
+ * uppercase auth roles can coexist safely during the transition.
*/
export function useRoleGuard() {
- const role =
+ const storedRole =
typeof window !== "undefined"
- ? localStorage.getItem("petad_user_role")
+ ? localStorage.getItem("petad_user_role") ?? localStorage.getItem("role")
: null;
+ const normalizedRole = storedRole?.toUpperCase() ?? "";
+ const role = normalizedRole as UserRole | "";
+
const hasAccess = (roles: string[]) => {
if (!role) return false;
- return roles.includes(role);
+ return roles.map((value) => value.toUpperCase()).includes(role);
};
return {
- role: role || "",
- isAdmin: role === "admin",
- isUser: role === "user",
+ role,
+ isAdmin: role === "ADMIN",
+ isShelter: role === "SHELTER",
+ isUser: role === "USER",
+ canApprove: role === "ADMIN" || role === "SHELTER",
hasAccess,
};
}
diff --git a/src/lib/hooks/__tests__/useDisputeCount.test.tsx b/src/lib/hooks/__tests__/useDisputeCount.test.tsx
index b01dc54..c864c00 100644
--- a/src/lib/hooks/__tests__/useDisputeCount.test.tsx
+++ b/src/lib/hooks/__tests__/useDisputeCount.test.tsx
@@ -43,7 +43,14 @@ describe("useDisputeCount", () => {
});
it("admin sees total count of open and under_review disputes", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: true,
+ isShelter: false,
+ isUser: false,
+ canApprove: true,
+ role: "ADMIN",
+ hasAccess: vi.fn().mockReturnValue(true),
+ });
// open: 2 disputes, under_review: 1 dispute → total: 3
mockGet
.mockResolvedValueOnce({ data: [{ id: "d1" }, { id: "d2" }] })
@@ -59,7 +66,14 @@ describe("useDisputeCount", () => {
});
it("admin fetches both open and under_review endpoints", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: true,
+ isShelter: false,
+ isUser: false,
+ canApprove: true,
+ role: "ADMIN",
+ hasAccess: vi.fn().mockReturnValue(true),
+ });
mockGet
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: [] });
@@ -75,7 +89,14 @@ describe("useDisputeCount", () => {
});
it("user sees only their own open disputes (single endpoint)", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ role: "USER",
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
mockGet.mockResolvedValueOnce({ data: [{ id: "d1" }] });
const queryClient = createTestQueryClient();
@@ -89,7 +110,14 @@ describe("useDisputeCount", () => {
});
it("displays '9+' when count exceeds 9", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: true,
+ isShelter: false,
+ isUser: false,
+ canApprove: true,
+ role: "ADMIN",
+ hasAccess: vi.fn().mockReturnValue(true),
+ });
// open: 7, under_review: 5 → total: 12
mockGet
.mockResolvedValueOnce({ data: Array.from({ length: 7 }, (_, i) => ({ id: `o${i}` })) })
@@ -105,7 +133,14 @@ describe("useDisputeCount", () => {
});
it("displays exact count when count is exactly 9", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ role: "USER",
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
mockGet.mockResolvedValueOnce({
data: Array.from({ length: 9 }, (_, i) => ({ id: `d${i}` })),
});
@@ -120,7 +155,14 @@ describe("useDisputeCount", () => {
});
it("resets count to 0 when user visits /disputes", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ role: "USER",
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
mockGet.mockResolvedValue({ data: [] });
const queryClient = createTestQueryClient();
@@ -135,7 +177,14 @@ describe("useDisputeCount", () => {
});
it("resets count to 0 when admin visits /admin/disputes", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: true, isUser: false, role: "admin" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: true,
+ isShelter: false,
+ isUser: false,
+ canApprove: true,
+ role: "ADMIN",
+ hasAccess: vi.fn().mockReturnValue(true),
+ });
mockGet.mockResolvedValue({ data: [] });
const queryClient = createTestQueryClient();
@@ -149,7 +198,14 @@ describe("useDisputeCount", () => {
});
it("does not reset count when visiting an unrelated route", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ role: "USER",
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
// refetchOnMount: false → cached value persists, no immediate fetch
mockGet.mockResolvedValue({ data: [] });
@@ -166,7 +222,14 @@ describe("useDisputeCount", () => {
});
it("returns count 0 when no disputes exist", async () => {
- mockUseRoleGuard.mockReturnValue({ isAdmin: false, isUser: true, role: "user" });
+ mockUseRoleGuard.mockReturnValue({
+ isAdmin: false,
+ isShelter: false,
+ isUser: true,
+ canApprove: false,
+ role: "USER",
+ hasAccess: vi.fn().mockReturnValue(false),
+ });
mockGet.mockResolvedValueOnce({ data: [] });
const queryClient = createTestQueryClient();
@@ -178,4 +241,4 @@ describe("useDisputeCount", () => {
expect(result.current.count).toBe(0);
expect(result.current.displayCount).toBe("0");
});
-});
\ No newline at end of file
+});
diff --git a/src/main.tsx b/src/main.tsx
index 124de81..8d7d52a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -52,7 +52,7 @@ async function bootstrap() {
return
}
- if (import.meta.env.VITE_MSW === 'true') {
+ if (import.meta.env.DEV && import.meta.env.VITE_MSW === 'true') {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: 'warn',
diff --git a/src/mocks/handlers/approval.ts b/src/mocks/handlers/approval.ts
index 637d2dd..47c16f7 100644
--- a/src/mocks/handlers/approval.ts
+++ b/src/mocks/handlers/approval.ts
@@ -30,6 +30,22 @@ export const approvalHandlers = [
]);
}),
+ // GET /api/shelter/approvals?status=PENDING&limit=0 — pending approval count
+ http.get(`${BASE_URL}/shelter/approvals`, async ({ request }: { request: Request }) => {
+ await delay(500);
+ const url = new URL(request.url);
+ const status = url.searchParams.get("status");
+ const limit = url.searchParams.get("limit");
+
+ if (status !== "PENDING" || limit !== "0") {
+ return HttpResponse.json({ count: 0 });
+ }
+
+ return HttpResponse.json({
+ count: 12,
+ });
+ }),
+
// GET /api/admin/approvals — admin approval queue
http.get(`${BASE_URL}/admin/approvals`, async ({ request }: { request: Request }) => {
await delay(1000);