diff --git a/apps/web/package.json b/apps/web/package.json index ee51f05d..11ebf116 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "dependencies": { "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 689052c6..f36e8c9f 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { AppConfigResponse } from "@/types/config"; +import type { AppConfigResponse, Network } from "@/types/config"; import type { PaginationOptions } from "@/types/pagination"; import type { ProvidersListResponseWithoutMetrics } from "@/types/providers"; @@ -31,10 +31,12 @@ const buildQueryString = (params: Record { +export async function fetchProvidersList( + options?: PaginationOptions & { network?: Network }, +): Promise { const queryString = options ? buildQueryString(options as Record) : ""; const url = `${getBaseUrl()}/api/v1/providers${queryString}`; diff --git a/apps/web/src/components/shared/Header/index.tsx b/apps/web/src/components/shared/Header/index.tsx index df9e18b1..31967a74 100644 --- a/apps/web/src/components/shared/Header/index.tsx +++ b/apps/web/src/components/shared/Header/index.tsx @@ -1,4 +1,4 @@ -import { ModeToggle, NetworkSwitcher, UIVersionToggle } from "@/components/shared"; +import { EnvironmentSwitcher, ModeToggle, UIVersionToggle } from "@/components/shared"; const Header = () => (
@@ -14,7 +14,7 @@ const Header = () => (
- +
diff --git a/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx b/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx new file mode 100644 index 00000000..418e9641 --- /dev/null +++ b/apps/web/src/components/shared/Network/EnvironmentSwitcher.test.tsx @@ -0,0 +1,117 @@ +import { server } from "@test/mocks/server"; +import { render, screen, waitFor } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import EnvironmentSwitcher from "./EnvironmentSwitcher"; + +const configUrl = "/api/config"; + +function makeConfig(network: "mainnet" | "calibration") { + return { + networks: [ + { + network, + dealsPerSpPerHour: 4, + retrievalsPerSpPerHour: 4, + dataSetCreationsPerSpPerHour: 0, + pullChecksPerSpPerHour: 0, + dataRetentionPollIntervalSeconds: 3600, + providersRefreshIntervalSeconds: 3600, + }, + ], + }; +} + +describe("EnvironmentSwitcher", () => { + it("links to Staging when current deployment monitors mainnet (Production)", async () => { + server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet")))); + + render( + + + , + ); + + const link = await screen.findByRole("link", { name: /Switch to Staging/i }); + expect(link).toHaveAttribute("href", "https://staging.dealbot.filoz.org"); + }); + + it("links to Production when current deployment monitors calibration (Staging)", async () => { + server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("calibration")))); + + render( + + + , + ); + + const link = await screen.findByRole("link", { name: /Switch to Production/i }); + expect(link).toHaveAttribute("href", "https://dealbot.filoz.org"); + }); + + it("shows loading skeleton initially", () => { + server.use(http.get(configUrl, () => new Promise(() => {}))); + + const { container } = render( + + + , + ); + + expect(container.querySelector(".animate-pulse")).toBeInTheDocument(); + }); + + it("hides component when config fails to load", async () => { + server.use(http.get(configUrl, () => new HttpResponse(null, { status: 500 }))); + + const { container } = render( + + + , + ); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it("opens link with rel=noreferrer", async () => { + server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet")))); + + render( + + + , + ); + + const link = await screen.findByRole("link", { name: /Switch to Staging/i }); + expect(link).toHaveAttribute("rel", "noreferrer"); + }); + + it("shows amber dot when linking to Staging (calibration)", async () => { + server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("mainnet")))); + + const { container } = render( + + + , + ); + + await screen.findByRole("link", { name: /Switch to Staging/i }); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + }); + + it("shows emerald dot when linking to Production (mainnet)", async () => { + server.use(http.get(configUrl, () => HttpResponse.json(makeConfig("calibration")))); + + const { container } = render( + + + , + ); + + await screen.findByRole("link", { name: /Switch to Production/i }); + expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx b/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx new file mode 100644 index 00000000..1c297439 --- /dev/null +++ b/apps/web/src/components/shared/Network/EnvironmentSwitcher.tsx @@ -0,0 +1,35 @@ +import { ArrowLeftRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useActiveNetworks } from "@/hooks/useActiveNetworks"; +import { ENVIRONMENT_LABEL, NETWORK_DEPLOYMENT_URL, NETWORK_DOT_CLASS } from "./constants"; + +/** + * Header control that links to the sibling deployment for the other environment. + * + * Environment is determined by which networks the current deployment monitors: + * - Active networks include mainnet → Production deployment → link to Staging + * - Active networks are calibration-only → Staging deployment → link to Production + */ +export default function EnvironmentSwitcher() { + const { activeNetworks, loading, error } = useActiveNetworks(); + + if (loading) { + return
; + } + + if (error || activeNetworks.length === 0) return null; + + const isProduction = activeNetworks.includes("mainnet"); + const targetEnv = isProduction ? "calibration" : "mainnet"; + const label = `Switch to ${ENVIRONMENT_LABEL[targetEnv]}`; + + return ( + + ); +} diff --git a/apps/web/src/components/shared/Network/NetworkBadge.tsx b/apps/web/src/components/shared/Network/NetworkBadge.tsx index 15b77e7e..d5cfae98 100644 --- a/apps/web/src/components/shared/Network/NetworkBadge.tsx +++ b/apps/web/src/components/shared/Network/NetworkBadge.tsx @@ -1,16 +1,20 @@ -import { useNetworkConfig } from "@/hooks/useNetworkConfig"; +import type { Network } from "@/types/config"; import { NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants"; -function NetworkBadge({ className }: { className?: string }) { - const { network, loading, error } = useNetworkConfig(); +interface NetworkBadgeProps { + network: Network | null; + loading?: boolean; + className?: string; +} +function NetworkBadge({ network, loading = false, className }: NetworkBadgeProps) { if (loading) { return ( ); } - if (error || network === null) return null; + if (network === null) return null; return ( { - it("shows current network and a link to switch to the other deployment (mainnet → calibration)", async () => { - server.use( - http.get(configUrl, () => - HttpResponse.json({ - network: "mainnet", - jobs: {}, - }), - ), - ); - - render( - - - , - ); - - const switchLink = await screen.findByRole("link", { name: /Switch to Calibration/i }); - expect(switchLink).toHaveAttribute("href", "https://staging.dealbot.filoz.org"); + it("renders nothing when only one network is active", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); }); - it("offers switching to mainnet when current instance monitors calibration", async () => { - server.use( - http.get(configUrl, () => - HttpResponse.json({ - network: "calibration", - jobs: {}, - }), - ), - ); - - render( - - - , - ); - - const switchLink = await screen.findByRole("link", { name: /Switch to Mainnet/i }); - expect(switchLink).toHaveAttribute("href", "https://dealbot.filoz.org"); + it("renders a tab for each network when multiple are active", () => { + render(); + expect(screen.getByRole("tab", { name: /mainnet/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /calibration/i })).toBeInTheDocument(); }); - it("shows loading state initially", () => { - server.use(http.get(configUrl, () => new Promise(() => {}))); - - const { container } = render( - - - , - ); - - const loadingElement = container.querySelector(".animate-pulse"); - expect(loadingElement).toBeInTheDocument(); - expect(loadingElement).toHaveClass("bg-muted"); + it("marks the selected network tab as aria-selected", () => { + render(); + expect(screen.getByRole("tab", { name: /calibration/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: /mainnet/i })).toHaveAttribute("aria-selected", "false"); }); - it("hides component when network config fails to load", async () => { - server.use( - http.get(configUrl, () => { - return new HttpResponse(null, { status: 500 }); - }), - ); + it("calls onChange with the clicked network", async () => { + const onChange = vi.fn(); + render(); - const { container } = render( - - - , - ); - - await waitFor(() => { - expect(container.firstChild).toBeNull(); - }); - }); - - it("opens link in new tab with proper security attributes", async () => { - server.use( - http.get(configUrl, () => - HttpResponse.json({ - network: "mainnet", - jobs: {}, - }), - ), - ); - - render( - - - , - ); - - const switchLink = await screen.findByRole("link", { name: /Switch to Calibration/i }); - expect(switchLink).toHaveAttribute("rel", "noreferrer"); + await userEvent.click(screen.getByRole("tab", { name: /calibration/i })); + expect(onChange).toHaveBeenCalledWith("calibration"); }); - it("displays correct network indicator color for calibration", async () => { - server.use( - http.get(configUrl, () => - HttpResponse.json({ - network: "calibration", - jobs: {}, - }), - ), - ); - + it("shows emerald dot for mainnet tab", () => { const { container } = render( - - - , + , ); - - await screen.findByRole("link", { name: /Switch to Mainnet/i }); - - const networkDot = container.querySelector(".bg-emerald-500"); - expect(networkDot).toBeInTheDocument(); + expect(container.querySelector(".bg-emerald-500")).toBeInTheDocument(); }); - it("displays correct network indicator color for mainnet", async () => { - server.use( - http.get(configUrl, () => - HttpResponse.json({ - network: "mainnet", - jobs: {}, - }), - ), - ); - + it("shows amber dot for calibration tab", () => { const { container } = render( - - - , + , ); + expect(container.querySelector(".bg-amber-500")).toBeInTheDocument(); + }); - await screen.findByRole("link", { name: /Switch to Calibration/i }); - - const networkDot = container.querySelector(".bg-amber-500"); - expect(networkDot).toBeInTheDocument(); + it("has role=tablist on the container", () => { + render(); + expect(screen.getByRole("tablist")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/shared/Network/NetworkSwitcher.tsx b/apps/web/src/components/shared/Network/NetworkSwitcher.tsx index fe1d0559..156852cc 100644 --- a/apps/web/src/components/shared/Network/NetworkSwitcher.tsx +++ b/apps/web/src/components/shared/Network/NetworkSwitcher.tsx @@ -1,33 +1,30 @@ -import { ArrowLeftRight } from "lucide-react"; -import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { useNetworkConfig } from "@/hooks/useNetworkConfig"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Network } from "@/types/config"; -import { NETWORK_DEPLOYMENT_URL, NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants"; +import { NETWORK_DOT_CLASS, NETWORK_LABEL } from "./constants"; -const otherNetwork = (network: Network): Network => (network === "mainnet" ? "calibration" : "mainnet"); +interface NetworkSwitcherProps { + networks: Network[]; + selected: Network; + onChange: (network: Network) => void; +} /** - * Provides a link to the sibling deployment for the other network. + * In-page tab control for switching between the active networks of a + * multi-network deployment. Renders nothing when only one network is active. */ -export default function NetworkSwitcher() { - const { network, loading, error } = useNetworkConfig(); - - if (loading) { - return
; - } - - if (error || network === null) return null; +export default function NetworkSwitcher({ networks, selected, onChange }: NetworkSwitcherProps) { + if (networks.length <= 1) return null; - const other = otherNetwork(network); - const label = `Switch to ${NETWORK_LABEL[other]}`; return ( - + onChange(v as Network)}> + + {networks.map((network) => ( + + + {NETWORK_LABEL[network]} + + ))} + + ); } diff --git a/apps/web/src/components/shared/Network/constants.ts b/apps/web/src/components/shared/Network/constants.ts index 7b3d8f20..1e8bd927 100644 --- a/apps/web/src/components/shared/Network/constants.ts +++ b/apps/web/src/components/shared/Network/constants.ts @@ -1,16 +1,29 @@ import type { Network } from "@/types/config"; +/** External URLs for the sibling deployment of each environment. */ export const NETWORK_DEPLOYMENT_URL: Record = { mainnet: "https://dealbot.filoz.org", calibration: "https://staging.dealbot.filoz.org", }; +/** Human-readable network name used in the in-page network switcher and badges. */ export const NETWORK_LABEL: Record = { mainnet: "Mainnet", calibration: "Calibration", }; +/** Tailwind dot color per network. */ export const NETWORK_DOT_CLASS: Record = { mainnet: "bg-emerald-500", calibration: "bg-amber-500", }; + +/** + * Environment label per network. + * A deployment whose active networks include mainnet is "Production"; + * a calibration-only deployment is "Staging". + */ +export const ENVIRONMENT_LABEL: Record = { + mainnet: "Production", + calibration: "Staging", +}; diff --git a/apps/web/src/components/shared/index.ts b/apps/web/src/components/shared/index.ts index 29c40bca..4d1809bd 100644 --- a/apps/web/src/components/shared/index.ts +++ b/apps/web/src/components/shared/index.ts @@ -2,9 +2,20 @@ import ComingSoon from "./ComingSoon"; import Footer from "./Footer"; import Header from "./Header"; import ModeToggle from "./ModeToggle"; +import EnvironmentSwitcher from "./Network/EnvironmentSwitcher"; import NetworkBadge from "./Network/NetworkBadge"; import NetworkSwitcher from "./Network/NetworkSwitcher"; import ThemeProvider from "./ThemeProvider"; import UIVersionToggle from "./UIVersionToggle"; -export { ComingSoon, Footer, Header, ModeToggle, NetworkBadge, NetworkSwitcher, ThemeProvider, UIVersionToggle }; +export { + ComingSoon, + EnvironmentSwitcher, + Footer, + Header, + ModeToggle, + NetworkBadge, + NetworkSwitcher, + ThemeProvider, + UIVersionToggle, +}; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 00000000..4a59fd1d --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,69 @@ +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, orientation = "horizontal", ...props }: React.ComponentProps) { + return ( + + ); +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ; +} + +export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger }; diff --git a/apps/web/src/hooks/useNetworkConfig.ts b/apps/web/src/hooks/useActiveNetworks.ts similarity index 66% rename from apps/web/src/hooks/useNetworkConfig.ts rename to apps/web/src/hooks/useActiveNetworks.ts index da4fc4ea..ccbf5f19 100644 --- a/apps/web/src/hooks/useNetworkConfig.ts +++ b/apps/web/src/hooks/useActiveNetworks.ts @@ -2,17 +2,18 @@ import { useEffect, useState } from "react"; import { fetchAppConfig } from "@/api/client"; import type { Network } from "@/types/config"; -interface UseNetworkConfigReturn { - network: Network | null; +interface UseActiveNetworksReturn { + activeNetworks: Network[]; loading: boolean; error: string | null; } /** - * Fetch the dealbot app config and expose the network this instance monitors. + * Fetches the dealbot app config and returns the list of networks this + * deployment is actively monitoring (e.g. ["calibration"] or ["calibration", "mainnet"]). */ -export function useNetworkConfig(): UseNetworkConfigReturn { - const [network, setNetwork] = useState(null); +export function useActiveNetworks(): UseActiveNetworksReturn { + const [activeNetworks, setActiveNetworks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -25,7 +26,7 @@ export function useNetworkConfig(): UseNetworkConfigReturn { setError(null); const data = await fetchAppConfig(controller.signal); if (controller.signal.aborted) return; - setNetwork(data.network); + setActiveNetworks(data.networks.map((n) => n.network)); } catch (err) { if (controller.signal.aborted) return; setError(err instanceof Error ? err.message : "Failed to fetch app config"); @@ -38,9 +39,5 @@ export function useNetworkConfig(): UseNetworkConfigReturn { return () => controller.abort(); }, []); - return { - network, - loading, - error, - }; + return { activeNetworks, loading, error }; } diff --git a/apps/web/src/hooks/useProvidersList.ts b/apps/web/src/hooks/useProvidersList.ts index 626c7b84..6bf9c57f 100644 --- a/apps/web/src/hooks/useProvidersList.ts +++ b/apps/web/src/hooks/useProvidersList.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { fetchProvidersList } from "@/api/client"; +import type { Network } from "@/types/config"; import type { ProvidersListResponseWithoutMetrics } from "@/types/providers"; interface UseProvidersListReturn { @@ -8,38 +9,54 @@ interface UseProvidersListReturn { error: string | null; } -export function useProvidersList(offset = 0, limit = 20): UseProvidersListReturn { - const [providers, setProviders] = useState({ - providers: [], - count: 0, - limit: 20, - offset: 0, - total: 0, - }); +const EMPTY_RESPONSE: ProvidersListResponseWithoutMetrics = { + providers: [], + count: 0, + limit: 20, + offset: 0, + total: 0, +}; + +/** + * Fetches a paginated providers list scoped to the given `network`. + * + * Pass `null` to suspend fetching (e.g. while the active-network config is + * still loading) — the hook stays in loading state without issuing a request. + * Pass `undefined` to fetch providers for all active networks (no filter). + */ +export function useProvidersList( + offset = 0, + limit = 20, + network: Network | null | undefined = undefined, +): UseProvidersListReturn { + const [providers, setProviders] = useState(EMPTY_RESPONSE); + // Start in loading state so callers always see a spinner before the first fetch resolves. const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + // null means "wait for the selected network to be determined" — skip the fetch entirely. + if (network === null) return; + let isMounted = true; const loadProviders = async () => { try { setLoading(true); setError(null); - const data = await fetchProvidersList({ offset, limit }); - - if (isMounted) { - setProviders(data); - } + const data = await fetchProvidersList({ + offset, + limit, + ...(network !== undefined ? { network } : {}), + }); + if (isMounted) setProviders(data); } catch (err) { if (isMounted) { setError(err instanceof Error ? err.message : "Failed to fetch providers list"); console.error("Error fetching providers list:", err); } } finally { - if (isMounted) { - setLoading(false); - } + if (isMounted) setLoading(false); } }; @@ -48,7 +65,7 @@ export function useProvidersList(offset = 0, limit = 20): UseProvidersListReturn return () => { isMounted = false; }; - }, [offset, limit]); + }, [offset, limit, network]); return { providers, loading, error }; } diff --git a/apps/web/src/hooks/useSelectedNetwork.ts b/apps/web/src/hooks/useSelectedNetwork.ts new file mode 100644 index 00000000..3812aa01 --- /dev/null +++ b/apps/web/src/hooks/useSelectedNetwork.ts @@ -0,0 +1,35 @@ +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { Network } from "@/types/config"; + +/** + * Derives the selected network from the `?network=` URL search param, falling + * back to `activeNetworks[0]` when the param is absent or invalid. + * + * Returns the selected network and a stable setter that writes the param to the + * URL (replacing history so network switches don't stack in the back button). + * + * Returns `[null, setter]` while `activeNetworks` is still empty (loading). + */ +export function useSelectedNetwork(activeNetworks: Network[]): [Network | null, (n: Network) => void] { + const [searchParams, setSearchParams] = useSearchParams(); + + const param = searchParams.get("network") as Network | null; + const selected = param !== null && activeNetworks.includes(param) ? param : (activeNetworks[0] ?? null); + + const setSelectedNetwork = useCallback( + (network: Network) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set("network", network); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + return [selected, setSelectedNetwork]; +} diff --git a/apps/web/src/pages/Landing.test.tsx b/apps/web/src/pages/Landing.test.tsx index f1750674..9104e27b 100644 --- a/apps/web/src/pages/Landing.test.tsx +++ b/apps/web/src/pages/Landing.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { ProvidersListResponseWithoutMetrics } from "@/types/providers"; @@ -15,6 +16,14 @@ vi.mock("@/hooks/useProvidersList", () => ({ useProvidersList: (...args: unknown[]) => mockUseProvidersList(...args), })); +vi.mock("@/hooks/useActiveNetworks", () => ({ + useActiveNetworks: () => ({ activeNetworks: ["mainnet"], loading: false, error: null }), +})); + +vi.mock("@/hooks/useSelectedNetwork", () => ({ + useSelectedNetwork: () => ["mainnet", vi.fn()], +})); + import Landing from "./Landing"; function makeProvider(overrides: Record = {}) { @@ -28,6 +37,7 @@ function makeProvider(overrides: Record = {}) { isActive: true, isApproved: true, region: "US", + network: "mainnet", metadata: {}, createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-01-01T00:00:00Z", @@ -49,16 +59,24 @@ function setupMock(providers: ReturnType[] = [makeProvider( }); } +function renderLanding() { + return render( + + + , + ); +} + describe("Landing", () => { it("renders string providerId in the table", () => { setupMock([makeProvider({ providerId: "42" })]); - render(); + renderLanding(); expect(screen.getByText("42")).toBeInTheDocument(); }); it("renders dash when providerId is null", () => { setupMock([makeProvider({ providerId: null })]); - render(); + renderLanding(); const row = screen.getByText("Test Provider").closest("tr")!; const cells = row.querySelectorAll("td"); expect(cells[1].textContent).toBe("—"); diff --git a/apps/web/src/pages/Landing.tsx b/apps/web/src/pages/Landing.tsx index f24bebaa..b51c360e 100644 --- a/apps/web/src/pages/Landing.tsx +++ b/apps/web/src/pages/Landing.tsx @@ -1,10 +1,11 @@ import { Activity, ExternalLink, LineChart } from "lucide-react"; -import { NetworkBadge } from "@/components/shared"; +import { NetworkBadge, NetworkSwitcher } from "@/components/shared"; import { NETWORK_LABEL } from "@/components/shared/Network/constants"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useNetworkConfig } from "@/hooks/useNetworkConfig"; +import { useActiveNetworks } from "@/hooks/useActiveNetworks"; import { useProvidersList } from "@/hooks/useProvidersList"; +import { useSelectedNetwork } from "@/hooks/useSelectedNetwork"; import type { Network } from "@/types/config"; /** @@ -80,7 +81,8 @@ const getConfig = (network: Network | null) => { }; export default function Landing() { - const { network } = useNetworkConfig(); + const { activeNetworks, loading: configLoading } = useActiveNetworks(); + const [selectedNetwork, setSelectedNetwork] = useSelectedNetwork(activeNetworks); const { dashboardUrl, dashboardUrlInvalid, @@ -88,8 +90,13 @@ export default function Landing() { approvedSpDashboardUrlInvalid, logsUrl, logsUrlInvalid, - } = getConfig(network); - const { providers: providersResponse, loading: providersLoading, error: providersError } = useProvidersList(0, 500); + } = getConfig(selectedNetwork); + // Pass null while config is loading to defer the fetch; once resolved, scope to the selected network. + const { + providers: providersResponse, + loading: providersLoading, + error: providersError, + } = useProvidersList(0, 500, configLoading ? null : (selectedNetwork ?? undefined)); return (
@@ -101,7 +108,7 @@ export default function Landing() {
- +

@@ -130,10 +137,10 @@ export default function Landing() { See the approval methodology ↗

- {approvedSpDashboardUrlInvalid && network && ( + {approvedSpDashboardUrlInvalid && selectedNetwork && (

- Warning: APPROVED_SP_DASHBOARD_URL_{network.toUpperCase()} (or{" "} - VITE_APPROVED_SP_DASHBOARD_URL_{network.toUpperCase()}) configured but invalid. Link + Warning: APPROVED_SP_DASHBOARD_URL_{selectedNetwork.toUpperCase()} (or{" "} + VITE_APPROVED_SP_DASHBOARD_URL_{selectedNetwork.toUpperCase()}) configured but invalid. Link unavailable.

)} @@ -151,7 +158,7 @@ export default function Landing() {
{/* Combined approved-SP dashboard CTA */} - {approvedSpDashboardUrl && network && ( + {approvedSpDashboardUrl && selectedNetwork && (
@@ -159,7 +166,7 @@ export default function Landing() {

Filecoin Onchain Cloud: approved SP performance

- Aggregated metrics across all SPs approved for FOC storage on {NETWORK_LABEL[network]}. + Aggregated metrics across all SPs approved for FOC storage on {NETWORK_LABEL[selectedNetwork]}.

@@ -176,7 +183,12 @@ export default function Landing() { {/* Storage providers – metrics & logs */} - Storage providers – metrics & logs +
+ Storage providers – metrics & logs + {selectedNetwork !== null && ( + + )} +
{(dashboardUrlInvalid || logsUrlInvalid) && ( diff --git a/apps/web/src/types/config.ts b/apps/web/src/types/config.ts index cb7692d8..77435e62 100644 --- a/apps/web/src/types/config.ts +++ b/apps/web/src/types/config.ts @@ -1,10 +1,15 @@ export type Network = "mainnet" | "calibration"; -export interface AppConfigResponse { +export interface NetworkConfig { network: Network; - jobs: { - dealsPerSpPerHour?: number; - dataSetCreationsPerSpPerHour?: number; - retrievalsPerSpPerHour?: number; - }; + dealsPerSpPerHour: number; + retrievalsPerSpPerHour: number; + dataSetCreationsPerSpPerHour: number; + pullChecksPerSpPerHour: number; + dataRetentionPollIntervalSeconds: number; + providersRefreshIntervalSeconds: number; +} + +export interface AppConfigResponse { + networks: NetworkConfig[]; } diff --git a/apps/web/src/types/providers.ts b/apps/web/src/types/providers.ts index d2a50c18..59b41628 100644 --- a/apps/web/src/types/providers.ts +++ b/apps/web/src/types/providers.ts @@ -28,6 +28,7 @@ export interface PDPOffering { */ export interface Provider { address: string; + network: string; providerId?: string | null; name: string; description: string; diff --git a/apps/web/test/mocks/handlers/config.ts b/apps/web/test/mocks/handlers/config.ts index a7b7a040..f49536de 100644 --- a/apps/web/test/mocks/handlers/config.ts +++ b/apps/web/test/mocks/handlers/config.ts @@ -2,7 +2,16 @@ import { HttpResponse, http } from "msw"; export const configHandler = http.get("/api/config", () => { return HttpResponse.json({ - network: "mainnet", - jobs: {}, + networks: [ + { + network: "mainnet", + dealsPerSpPerHour: 4, + retrievalsPerSpPerHour: 4, + dataSetCreationsPerSpPerHour: 0, + pullChecksPerSpPerHour: 0, + dataRetentionPollIntervalSeconds: 3600, + providersRefreshIntervalSeconds: 3600, + }, + ], }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23079017..b70a138e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.4(vite@7.3.1(@types/node@25.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.47.1)(yaml@2.8.4)) @@ -573,24 +576,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.14': resolution: {integrity: sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.14': resolution: {integrity: sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.14': resolution: {integrity: sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.14': resolution: {integrity: sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A==} @@ -1814,6 +1821,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -1832,6 +1852,37 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -1845,6 +1896,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -1876,6 +1940,28 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -2037,56 +2123,67 @@ packages: resolution: {integrity: sha512-UPMMNeC4LXW7ZSHxeP3Edv09aLsFUMaD1TSVW6n1CWMECnUIJMFFB7+XC2lZTdPtvB36tYC0cJWc86mzSsaviw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.4': resolution: {integrity: sha512-H8uwlV0otHs5Q7WAMSoyvjV9DJPiy5nJ/xnHolY0QptLPjaSsuX7tw+SPIfiYH6cnVx3fe4EWFafo6gH6ekZKA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.4': resolution: {integrity: sha512-BLRwSRwICXz0TXkbIbqJ1ibK+/dSBpTJqDClF61GWIrxTXZWQE78ROeIhgl5MjVs4B4gSLPCFeD4xML9vbzvCQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.4': resolution: {integrity: sha512-6bySEjOTbmVcPJAywjpGLckK793A0TJWSbIa0sVwtVGfe/Nz6gOWHOwkshUIAp9j7wg2WKcA4Snu7Y1nUZyQew==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.4': resolution: {integrity: sha512-U0ow3bXYJZ5MIbchVusxEycBw7bO6C2u5UvD31i5IMTrnt2p4Fh4ZbHSdc/31TScIJQYHwxbj05BpevB3201ug==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.4': resolution: {integrity: sha512-iujDk07ZNwGLVn0YIWM80SFN039bHZHCdCCuX9nyx3Jsa2d9V/0Y32F+YadzwbvDxhSeVo9zefkoPnXEImnM5w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.4': resolution: {integrity: sha512-MUtAktiOUSu+AXBpx1fkuG/Bi5rhlorGs3lw5QeJ2X3ziEGAq7vFNdWVde6XGaVqi0LGSvugwjoxSNJfHFTC0g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.4': resolution: {integrity: sha512-btm35eAbDfPtcFEgaXCI5l3c2WXyzwiE8pArhd66SDtoLWmgK5/M7CUxmUglkwtniPzwvWioBKKl6IXLbPf2sQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.4': resolution: {integrity: sha512-uJlhKE9ccUTCUlK+HUz/80cVtx2RayadC5ldDrrDUFaJK0SNb8/cCmC9RhBhIWuZ71Nqj4Uoa9+xljKWRogdhA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.4': resolution: {integrity: sha512-jjEMkzvASQBbzzlzf4os7nzSBd/cvPrpqXCUOqoeCh1dQ4BP3RZCJk8XBeik4MUln3m+8LeTJcY54C/u8wb3DQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.4': resolution: {integrity: sha512-lu90KG06NNH19shC5rBPkrh6mrTpq5kviFylPBXQVpdEu0yzb0mDgyxLr6XdcGdBIQTH/UAhDJnL+APZTBu1aQ==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.4': resolution: {integrity: sha512-dFDcmLwsUzhAm/dn0+dMOQZoONVYBtgik0VuY/d5IJUUb787L3Ko/ibvTvddqhb3RaB7vFEozYevHN4ox22R/w==} @@ -2220,24 +2317,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -2314,24 +2415,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -4760,24 +4865,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -9412,6 +9521,18 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.5)': dependencies: react: 19.2.5 @@ -9424,6 +9545,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.5) @@ -9433,6 +9577,23 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) @@ -9462,6 +9623,28 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.5)': + dependencies: + react: 19.2.5 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.5)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.5)