Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 16 additions & 3 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { Link, useLocation } from "react-router-dom";
import { House, Eye, List, Heart, Bell, ChevronDown } from "lucide-react";
import { House, Eye, List, Heart, Bell, ChevronDown, Scale } from "lucide-react";
import logo from "../../assets/logo.svg";
import owner from "../../assets/owner.png";
import { DisputeNavBadge } from "../ui/DisputeNavBadge";
import { useRoleGuard } from "../../hooks/useRoleGuard";

const navLinks = [
const BASE_NAV_LINKS = [
{ label: "Home", path: "/home", icon: House },
{ label: "Interests", path: "/interests", icon: Eye },
{ label: "Listings", path: "/listings", icon: List },
];

export function Navbar() {
const location = useLocation();
const { isAdmin } = useRoleGuard();

const disputesPath = isAdmin ? "/admin/disputes" : "/disputes";

const navLinks = [
...BASE_NAV_LINKS,
{ label: "Disputes", path: disputesPath, icon: Scale },
];

return (
<nav className="sticky top-0 z-50 w-full bg-white border-b border-gray-100 px-6 py-4 flex items-center justify-between">
Expand All @@ -36,12 +46,15 @@ export function Navbar() {
<Link
key={link.path}
to={link.path}
className={`flex items-center gap-2 text-[15px] font-medium transition-colors ${
className={`relative flex items-center gap-2 text-[15px] font-medium transition-colors ${
isActive ? "text-[#001323]" : "text-gray-500 hover:text-[#001323]"
}`}
>
<Icon size={20} strokeWidth={isActive ? 2.5 : 2} />
{link.label}
{link.label === "Disputes" && (
<DisputeNavBadge />
)}
</Link>
);
})}
Expand Down
23 changes: 23 additions & 0 deletions src/components/ui/DisputeNavBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

import { useDisputeCount } from "../../hooks/useDisputeCount";

interface DisputeNavBadgeProps {
currentUserId?: string;
}


export function DisputeNavBadge({ currentUserId }: DisputeNavBadgeProps) {
const { displayCount } = useDisputeCount(currentUserId);

if (!displayCount) return null;

return (
<span
data-testid="dispute-nav-badge"
aria-label={`${displayCount} active disputes`}
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full"
>
{displayCount}
</span>
);
}
92 changes: 92 additions & 0 deletions src/components/ui/__tests__/DisputeNavBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DisputeNavBadge } from "../DisputeNavBadge";

// Mock useDisputeCount so we control what the badge receives
vi.mock("../../../hooks/useDisputeCount", () => ({
useDisputeCount: vi.fn(),
formatDisputeCount: (n: number) => (n > 9 ? "9+" : n > 0 ? String(n) : ""),
}));

import { useDisputeCount } from "../../../hooks/useDisputeCount";
const mockUseDisputeCount = vi.mocked(useDisputeCount);

function renderBadge(currentUserId?: string) {
return render(
<MemoryRouter>
<DisputeNavBadge currentUserId={currentUserId} />
</MemoryRouter>,
);
}

describe("DisputeNavBadge", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders nothing when count is 0", () => {
mockUseDisputeCount.mockReturnValue({
count: 0,
displayCount: "",
isLoading: false,
});
renderBadge();
expect(screen.queryByTestId("dispute-nav-badge")).not.toBeInTheDocument();
});

it("admin sees total open dispute count", () => {
mockUseDisputeCount.mockReturnValue({
count: 3,
displayCount: "3",
isLoading: false,
});
renderBadge();
expect(screen.getByTestId("dispute-nav-badge")).toHaveTextContent("3");
});

it("user sees only their own dispute count", () => {
mockUseDisputeCount.mockReturnValue({
count: 1,
displayCount: "1",
isLoading: false,
});
renderBadge("user-buyer-2");
expect(screen.getByTestId("dispute-nav-badge")).toHaveTextContent("1");
});

it('displays "9+" when count exceeds 9', () => {
mockUseDisputeCount.mockReturnValue({
count: 12,
displayCount: "9+",
isLoading: false,
});
renderBadge();
expect(screen.getByTestId("dispute-nav-badge")).toHaveTextContent("9+");
});

it("resets to zero when on disputes page", () => {
mockUseDisputeCount.mockReturnValue({
count: 0,
displayCount: "",
isLoading: false,
});
render(
<MemoryRouter initialEntries={["/disputes"]}>
<DisputeNavBadge />
</MemoryRouter>,
);
expect(screen.queryByTestId("dispute-nav-badge")).not.toBeInTheDocument();
});

it("has accessible aria-label with count", () => {
mockUseDisputeCount.mockReturnValue({
count: 5,
displayCount: "5",
isLoading: false,
});
renderBadge();
const badge = screen.getByTestId("dispute-nav-badge");
expect(badge).toHaveAttribute("aria-label", "5 active disputes");
});
});
83 changes: 83 additions & 0 deletions src/hooks/useDisputeCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@

import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { usePolling } from "../lib/hooks/usePolling";
import { apiClient } from "../lib/api-client";
import { useRoleGuard } from "./useRoleGuard";

interface DisputeItem {
id: string;
raisedBy: string;
status: "open" | "under_review" | "resolved" | "closed";
}

const DISPUTE_ROUTES = ["/disputes", "/admin/disputes"];
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const MAX_DISPLAY = 9;


export function formatDisputeCount(count: number): string {
if (count <= 0) return "";
return count > MAX_DISPLAY ? `${MAX_DISPLAY}+` : String(count);
}

export interface UseDisputeCountResult {
count: number;
displayCount: string;
isLoading: boolean;
}


export function useDisputeCount(currentUserId?: string): UseDisputeCountResult {
const { isAdmin } = useRoleGuard();
const location = useLocation();
const [resetted, setResetted] = useState(false);

Check failure on line 34 in src/hooks/useDisputeCount.ts

View workflow job for this annotation

GitHub Actions / validate

'resetted' is assigned a value but never used

const isOnDisputePage = DISPUTE_ROUTES.some((route) =>
location.pathname.startsWith(route),
);

useEffect(() => {
if (isOnDisputePage) {
setResetted(true);
} else {
setResetted(false);
}
}, [isOnDisputePage]);

const query = usePolling<DisputeItem[]>(
["dispute-count", isAdmin ? "admin" : currentUserId ?? "guest"],
async () => {
const disputes = await apiClient.get<DisputeItem[]>(
"/api/disputes?status=OPEN",
);
return disputes;
},
{
intervalMs: POLL_INTERVAL_MS,
enabled: true,
},
);

const disputes = query.data ?? [];

const rawCount = isOnDisputePage
? 0
: isAdmin
? disputes.filter(
(d) => d.status === "open" || d.status === "under_review",
).length
: disputes.filter(
(d) =>
d.raisedBy === currentUserId &&
(d.status === "open" || d.status === "under_review"),
).length;

const count = isOnDisputePage ? 0 : rawCount;

return {
count,
displayCount: formatDisputeCount(count),
isLoading: query.isLoading,
};
}
Loading