diff --git a/soroscan-frontend/__tests__/breadcrumbs.test.tsx b/soroscan-frontend/__tests__/breadcrumbs.test.tsx new file mode 100644 index 000000000..91a35e3b6 --- /dev/null +++ b/soroscan-frontend/__tests__/breadcrumbs.test.tsx @@ -0,0 +1,102 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { Breadcrumbs } from "@/components/ui/breadcrumbs" + +// Mock next/navigation +const mockUsePathname = jest.fn() +jest.mock("next/navigation", () => ({ + usePathname: () => mockUsePathname(), +})) + +// Mock next-intl +jest.mock("next-intl", () => ({ + useTranslations: () => (key: string) => { + const messages: Record = { + home: "Home", + dashboard: "Dashboard", + contracts: "Contracts", + events: "Events", + } + return messages[key] || key + }, +})) + +// Mock next/link +jest.mock("next/link", () => { + const MockLink = ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ) + MockLink.displayName = "MockLink" + return MockLink +}) + +describe("Breadcrumbs", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders Home breadcrumb for root path", () => { + mockUsePathname.mockReturnValue("/en") + render() + + expect(screen.getByText("Home")).toBeInTheDocument() + // Current page is not a link + const homeText = screen.getByText("Home") + expect(homeText.tagName).not.toBe("A") + }) + + it("renders breadcrumbs for nested paths", () => { + mockUsePathname.mockReturnValue("/en/dashboard/contracts") + render() + + // Check Home link + const homeLink = screen.getByRole("link", { name: "Home" }) + expect(homeLink).toHaveAttribute("href", "/en") + + // Check Dashboard link + const dashboardLink = screen.getByRole("link", { name: "Dashboard" }) + expect(dashboardLink).toHaveAttribute("href", "/en/dashboard") + + // Check Contracts text (current page) + const contractsText = screen.getByText("Contracts") + expect(contractsText).toBeInTheDocument() + expect(contractsText.tagName).not.toBe("A") + }) + + it("handles dynamic segments by capitalizing them", () => { + mockUsePathname.mockReturnValue("/en/dashboard/contracts/some-contract") + render() + + expect(screen.getByText("Some-contract")).toBeInTheDocument() + }) + + it("shortens long segments", () => { + const longId = "CAS3J7H2Z7W7V7X7Y7Z7W7V7X7Y7Z7W7V7X7Y7Z7W7V7X7Y" + mockUsePathname.mockReturnValue(`/en/dashboard/contracts/${longId}`) + render() + + // Matches the shortened version: CAS3J7...7Y + expect(screen.getByText("CAS3J7...7Y")).toBeInTheDocument() + }) + + it("renders separators between items", () => { + mockUsePathname.mockReturnValue("/en/dashboard") + render() + + const listItems = screen.getAllByRole("listitem") + expect(listItems).toHaveLength(2) // Home, Dashboard + + // The second list item should contain a separator (ChevronRight) + // We can't easily check for the SVG but we can check the presence of the separator logic + // In our implementation, index > 0 shows the ChevronRight + }) + + it("handles paths without locale", () => { + mockUsePathname.mockReturnValue("/dashboard/events") + render() + + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("href", "/") + expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/dashboard") + expect(screen.getByText("Events")).toBeInTheDocument() + }) +}) diff --git a/soroscan-frontend/app/contracts/page.tsx b/soroscan-frontend/app/contracts/page.tsx index fc3822ccb..541b39800 100644 --- a/soroscan-frontend/app/contracts/page.tsx +++ b/soroscan-frontend/app/contracts/page.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import { Card } from "@/components/terminal/Card"; import { Button } from "@/components/terminal/Button"; import { ContractTable } from "./components/ContractTable"; @@ -68,6 +69,7 @@ export default function ContractsPage() { return (
+

diff --git a/soroscan-frontend/app/dashboard/page.tsx b/soroscan-frontend/app/dashboard/page.tsx index 37d74e1b0..a019a6633 100644 --- a/soroscan-frontend/app/dashboard/page.tsx +++ b/soroscan-frontend/app/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { Breadcrumbs } from "@/components/ui/breadcrumbs"; type HealthState = "healthy" | "degraded" | "critical"; @@ -50,6 +51,7 @@ export default function DashboardPage() { return (
+

[PERFORMANCE_DASHBOARD]

API Latency, Cache, and System Health

diff --git a/soroscan-frontend/app/docs/page.tsx b/soroscan-frontend/app/docs/page.tsx index 2c7b91d49..24ae88375 100644 --- a/soroscan-frontend/app/docs/page.tsx +++ b/soroscan-frontend/app/docs/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next" import "../styles/landing.css" import { Navbar } from "@/components/terminal/landing/Navbar" import { Footer } from "@/components/terminal/landing/Footer" +import { Breadcrumbs } from "@/components/ui/breadcrumbs" import { CodeSnippet } from "@/components/terminal/landing/CodeSnippet" import { Card } from "@/components/terminal/Card" import { Button } from "@/components/terminal/Button" @@ -79,8 +80,8 @@ export default function DocsPage() { return (
-
+ {/* Page header */}
diff --git a/soroscan-frontend/app/features/page.tsx b/soroscan-frontend/app/features/page.tsx index 52214bea6..6279c262e 100644 --- a/soroscan-frontend/app/features/page.tsx +++ b/soroscan-frontend/app/features/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next" import "../styles/landing.css" import { Navbar } from "@/components/terminal/landing/Navbar" import { Footer } from "@/components/terminal/landing/Footer" +import { Breadcrumbs } from "@/components/ui/breadcrumbs" import { Card } from "@/components/terminal/Card" import { Zap, Database, GitBranch, Webhook, Globe, Code2, @@ -109,8 +110,8 @@ export default function FeaturesPage() { return (
- -
+
+ {/* Page header */}
diff --git a/soroscan-frontend/app/settings/page.tsx b/soroscan-frontend/app/settings/page.tsx index 1f68448d5..c54581a84 100644 --- a/soroscan-frontend/app/settings/page.tsx +++ b/soroscan-frontend/app/settings/page.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import { Breadcrumbs } from "@/components/ui/breadcrumbs"; import ThemeSelector from "./components/ThemeSelector"; import NotificationPrefs from "./components/NotificationPrefs"; import APIKeyManager from "./components/APIKeyManager"; @@ -44,6 +45,7 @@ export default function SettingsPage() { return (
+ {/* Header */}

diff --git a/soroscan-frontend/app/webhooks/page.tsx b/soroscan-frontend/app/webhooks/page.tsx index 4d8dd12b3..cf1fa0218 100644 --- a/soroscan-frontend/app/webhooks/page.tsx +++ b/soroscan-frontend/app/webhooks/page.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Plus, Activity, CheckCircle2, AlertTriangle } from "lucide-react" import { Navbar } from "@/components/terminal/landing/Navbar" import { Footer } from "@/components/terminal/landing/Footer" +import { Breadcrumbs } from "@/components/ui/breadcrumbs" import { Button } from "@/components/terminal/Button" import { Modal } from "@/components/terminal/Modal" import { WebhookTable } from "./components/WebhookTable" @@ -79,8 +80,8 @@ export default function WebhooksPage() { return (
-
+ {/* Header */}
diff --git a/soroscan-frontend/components/ui/breadcrumbs.tsx b/soroscan-frontend/components/ui/breadcrumbs.tsx new file mode 100644 index 000000000..55e023fbb --- /dev/null +++ b/soroscan-frontend/components/ui/breadcrumbs.tsx @@ -0,0 +1,101 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useTranslations } from "next-intl" +import { ChevronRight } from "lucide-react" +import { cn } from "@/lib/utils" + +export interface BreadcrumbItem { + label: string + href: string + isCurrent: boolean +} + +export function Breadcrumbs({ className }: { className?: string }) { + const pathname = usePathname() + const t = useTranslations("Navigation") + + // Get all segments and filter out empty ones + const segments = pathname.split("/").filter(Boolean) + + // Check if the first segment is a locale (en or es) + const locales = ["en", "es"] + const hasLocale = locales.includes(segments[0]) + + // Extract path segments excluding the locale + const pathSegments = hasLocale ? segments.slice(1) : segments + + // Construct the breadcrumb items + const breadcrumbs: BreadcrumbItem[] = [ + { + label: t("home"), + href: hasLocale ? `/${segments[0]}` : "/", + isCurrent: pathSegments.length === 0, + }, + ...pathSegments.map((segment, index) => { + // Build href segment by segment + const currentPathSegments = pathSegments.slice(0, index + 1) + const href = hasLocale + ? `/${segments[0]}/${currentPathSegments.join("/")}` + : `/${currentPathSegments.join("/")}` + + const isCurrent = index === pathSegments.length - 1 + + // Try to get translation, fallback to capitalized segment + let label = segment + const knownKeys = ["dashboard", "contracts", "events", "webhooks", "docs", "features", "login"] + + if (knownKeys.includes(segment.toLowerCase())) { + try { + label = t(segment.toLowerCase()) + } catch { + label = segment.charAt(0).toUpperCase() + segment.slice(1) + } + } else { + // Handle potential IDs or unknown segments + if (segment.length > 20) { + label = `${segment.substring(0, 6)}...${segment.substring(segment.length - 4)}` + } else { + label = segment.charAt(0).toUpperCase() + segment.slice(1) + } + } + + return { + label, + href, + isCurrent, + } + }), + ] + + return ( + + ) +}