From 0201e05936fbd5eecd5ade491a17e6ed917e8a5f Mon Sep 17 00:00:00 2001 From: Ugasutun <141843972+Ugasutun@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:58:22 +0100 Subject: [PATCH 1/2] Implement interactive ELO progress dashboard. (#1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- frontend/__tests__/eloStatsUtils.test.ts | 130 ++ frontend/__tests__/useEloStats.test.ts | 47 + frontend/app/dashboard/page.tsx | 153 ++ frontend/components/GameSidebar.tsx | 31 +- frontend/components/dashboard/EloChart.tsx | 188 ++ .../dashboard/PerformanceBreakdown.tsx | 214 ++ frontend/components/dashboard/RankBadge.tsx | 91 + .../components/dashboard/RecentMatches.tsx | 102 + frontend/components/dashboard/StatCard.tsx | 86 + frontend/components/icons.tsx | 15 + frontend/constants/mockEloData.ts | 94 + frontend/hook/useEloStats.ts | 34 + frontend/lib/eloStatsUtils.ts | 145 ++ frontend/package-lock.json | 2012 ++++++++++++++++- frontend/package.json | 8 +- frontend/tsconfig.json | 5 +- frontend/vitest.config.ts | 14 + 17 files changed, 3236 insertions(+), 133 deletions(-) create mode 100644 frontend/__tests__/eloStatsUtils.test.ts create mode 100644 frontend/__tests__/useEloStats.test.ts create mode 100644 frontend/app/dashboard/page.tsx create mode 100644 frontend/components/dashboard/EloChart.tsx create mode 100644 frontend/components/dashboard/PerformanceBreakdown.tsx create mode 100644 frontend/components/dashboard/RankBadge.tsx create mode 100644 frontend/components/dashboard/RecentMatches.tsx create mode 100644 frontend/components/dashboard/StatCard.tsx create mode 100644 frontend/constants/mockEloData.ts create mode 100644 frontend/hook/useEloStats.ts create mode 100644 frontend/lib/eloStatsUtils.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/__tests__/eloStatsUtils.test.ts b/frontend/__tests__/eloStatsUtils.test.ts new file mode 100644 index 0000000..ec8e82d --- /dev/null +++ b/frontend/__tests__/eloStatsUtils.test.ts @@ -0,0 +1,130 @@ +import { EXTENDED_MOCK_ELO_DATA } from "@/constants/mockEloData"; +import { + computeEloStats, + computeStreak, + computeVolatility, + filterByTimeRange, + getRankTier, +} from "@/lib/eloStatsUtils"; +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; + +const SAMPLE_DATA: EloDataPoint[] = [ + { date: "2026-03-01", elo: 1215, opponent: "GA", change: 15 }, + { date: "2026-03-02", elo: 1205, opponent: "GB", change: -10 }, + { date: "2026-03-03", elo: 1205, opponent: "GC", change: 0 }, + { date: "2026-03-04", elo: 1223, opponent: "GD", change: 18 }, + { date: "2026-03-05", elo: 1239, opponent: "GE", change: 16 }, +]; + +describe("filterByTimeRange", () => { + it("returns the correct subset for each range", () => { + expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "7d")).toHaveLength(8); + expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "30d")).toHaveLength(31); + expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "90d")).toHaveLength(90); + expect(filterByTimeRange(EXTENDED_MOCK_ELO_DATA, "all")).toHaveLength(90); + }); + + it("returns an empty list for empty data", () => { + expect(filterByTimeRange([], "30d")).toEqual([]); + }); +}); + +describe("computeEloStats", () => { + it("computes aggregate rating metrics", () => { + const stats = computeEloStats(SAMPLE_DATA); + + expect(stats.currentElo).toBe(1239); + expect(stats.peakElo).toBe(1239); + expect(stats.lowestElo).toBe(1205); + expect(stats.totalGames).toBe(5); + expect(stats.wins).toBe(3); + expect(stats.losses).toBe(1); + expect(stats.draws).toBe(1); + expect(stats.winRate).toBeCloseTo(60); + expect(stats.currentStreak).toBe(2); + expect(stats.bestStreak).toBe(2); + expect(stats.avgChange).toBeCloseTo(7.8); + expect(stats.volatility).toBeCloseTo(computeVolatility(SAMPLE_DATA)); + }); + + it("handles empty arrays", () => { + expect(computeEloStats([])).toEqual({ + currentElo: 0, + peakElo: 0, + lowestElo: 0, + totalGames: 0, + wins: 0, + losses: 0, + draws: 0, + winRate: 0, + currentStreak: 0, + bestStreak: 0, + avgChange: 0, + volatility: 0, + }); + }); + + it("handles a single data point", () => { + const single = computeEloStats([{ date: "2026-03-01", elo: 1120, opponent: "GA", change: 20 }]); + + expect(single.currentElo).toBe(1120); + expect(single.totalGames).toBe(1); + expect(single.wins).toBe(1); + expect(single.currentStreak).toBe(1); + expect(single.bestStreak).toBe(1); + }); +}); + +describe("computeStreak", () => { + it("returns best and current streaks for all wins", () => { + const data = SAMPLE_DATA.filter((point) => point.change > 0); + expect(computeStreak(data)).toEqual({ current: 3, best: 3 }); + }); + + it("returns current loss streak for trailing losses", () => { + const data: EloDataPoint[] = [ + { date: "2026-03-01", elo: 1200, opponent: "GA", change: 12 }, + { date: "2026-03-02", elo: 1188, opponent: "GB", change: -12 }, + { date: "2026-03-03", elo: 1171, opponent: "GC", change: -17 }, + ]; + + expect(computeStreak(data)).toEqual({ current: -2, best: 1 }); + }); + + it("resets current streak on a draw", () => { + const data: EloDataPoint[] = [ + { date: "2026-03-01", elo: 1200, opponent: "GA", change: 12 }, + { date: "2026-03-02", elo: 1200, opponent: "GB", change: 0 }, + ]; + + expect(computeStreak(data)).toEqual({ current: 0, best: 1 }); + }); +}); + +describe("computeVolatility", () => { + it("returns zero for uniform changes", () => { + const data = [1, 2, 3].map((index) => ({ + date: `2026-03-0${index}`, + elo: 1200 + index * 10, + opponent: `G${index}`, + change: 10, + })); + + expect(computeVolatility(data)).toBe(0); + }); + + it("returns a positive standard deviation for varied changes", () => { + expect(computeVolatility(SAMPLE_DATA)).toBeCloseTo(10.9618, 3); + }); +}); + +describe("getRankTier", () => { + it("handles boundary values", () => { + expect(getRankTier(999).name).toBe("Beginner"); + expect(getRankTier(1000).name).toBe("Intermediate"); + expect(getRankTier(1200).name).toBe("Advanced"); + expect(getRankTier(1400).name).toBe("Expert"); + expect(getRankTier(1600).name).toBe("Master"); + expect(getRankTier(1800).name).toBe("Grandmaster"); + }); +}); diff --git a/frontend/__tests__/useEloStats.test.ts b/frontend/__tests__/useEloStats.test.ts new file mode 100644 index 0000000..5ac389b --- /dev/null +++ b/frontend/__tests__/useEloStats.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from "@testing-library/react"; +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; +import { EXTENDED_MOCK_ELO_DATA } from "@/constants/mockEloData"; +import { useEloStats } from "@/hook/useEloStats"; + +const TEST_DATA: EloDataPoint[] = [ + { date: "2026-03-01", elo: 1108, opponent: "GA", change: 8 }, + { date: "2026-03-04", elo: 1094, opponent: "GB", change: -14 }, + { date: "2026-03-09", elo: 1094, opponent: "GC", change: 0 }, + { date: "2026-03-20", elo: 1111, opponent: "GD", change: 17 }, + { date: "2026-03-26", elo: 1127, opponent: "GE", change: 16 }, +]; + +describe("useEloStats", () => { + it("returns computed stats for the selected range", () => { + const { result } = renderHook(() => useEloStats(TEST_DATA, "30d")); + + expect(result.current.currentElo).toBe(1127); + expect(result.current.totalGames).toBe(5); + expect(result.current.wins).toBe(3); + expect(result.current.losses).toBe(1); + expect(result.current.draws).toBe(1); + expect(result.current.winRate).toBeCloseTo(60); + }); + + it("memoizes the result when inputs do not change", () => { + const { result, rerender } = renderHook( + ({ data, range }) => useEloStats(data, range), + { + initialProps: { data: TEST_DATA, range: "30d" as const }, + }, + ); + const firstResult = result.current; + + rerender({ data: TEST_DATA, range: "30d" as const }); + + expect(result.current).toBe(firstResult); + }); + + it("filters by time range", () => { + const { result } = renderHook(() => useEloStats(EXTENDED_MOCK_ELO_DATA, "7d")); + + expect(result.current.filteredData).toHaveLength(8); + expect(result.current.filteredData[0]?.date).toBe("2026-03-19"); + expect(result.current.filteredData.at(-1)?.date).toBe("2026-03-26"); + }); +}); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..dc27bd0 --- /dev/null +++ b/frontend/app/dashboard/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { + BarChart3, + Flame, + Target, + TrendingDown, + TrendingUp, + Trophy, +} from "lucide-react"; +import RankBadge from "@/components/dashboard/RankBadge"; +import RecentMatches from "@/components/dashboard/RecentMatches"; +import StatCard from "@/components/dashboard/StatCard"; +import { EXTENDED_MOCK_ELO_DATA } from "@/constants/mockEloData"; +import { useEloStats, type TimeRange } from "@/hook/useEloStats"; +import { cn } from "@/lib/utils"; + +const EloChart = dynamic(() => import("@/components/dashboard/EloChart"), { + ssr: false, +}); +const PerformanceBreakdown = dynamic(() => import("@/components/dashboard/PerformanceBreakdown"), { + ssr: false, +}); + +const TIME_RANGES: Array<{ label: string; value: TimeRange }> = [ + { label: "7D", value: "7d" }, + { label: "30D", value: "30d" }, + { label: "90D", value: "90d" }, + { label: "ALL", value: "all" }, +]; + +export default function DashboardPage() { + const [range, setRange] = useState("30d"); + const stats = useEloStats(EXTENDED_MOCK_ELO_DATA, range); + const filteredData = stats.filteredData; + const baselineElo = filteredData[0]?.elo ?? stats.currentElo; + const netChange = stats.currentElo - baselineElo; + const subtitle = `${stats.currentElo} rating · ${stats.totalGames} games in view`; + + return ( +
+
+
+
+
+
+ + Rating intelligence +
+

ELO Progress

+

{subtitle}

+
+ +
+ {TIME_RANGES.map((option) => ( + + ))} +
+
+ +
+ +
+
+
+ +
+ } + label="Current ELO" + value={stats.currentElo} + trend={{ value: netChange, label: "range" }} + accentColor="teal" + /> + } + label="Peak Rating" + value={stats.peakElo} + trend={{ value: stats.peakElo - stats.currentElo, label: "to peak" }} + accentColor="blue" + /> + } + label="Win Rate" + value={`${stats.winRate.toFixed(1)}%`} + trend={{ value: stats.avgChange, label: "avg pts" }} + accentColor="emerald" + /> + = 0 ? : } + label="Current Streak" + value={`${stats.currentStreak > 0 ? "+" : ""}${stats.currentStreak}`} + trend={{ value: stats.volatility, label: "volatility" }} + accentColor="amber" + /> +
+ + + +
+ +
+
+
+ + Range insights +
+
+
+

Lowest point

+

{stats.lowestElo}

+
+
+

Games sampled

+

{stats.totalGames}

+
+
+

Best streak

+

{stats.bestStreak}

+
+
+
+
+
+ + +
+ ); +} diff --git a/frontend/components/GameSidebar.tsx b/frontend/components/GameSidebar.tsx index 40c94a7..d12494e 100644 --- a/frontend/components/GameSidebar.tsx +++ b/frontend/components/GameSidebar.tsx @@ -3,6 +3,7 @@ import type React from "react" import Image from "next/image" import Link from "next/link" +import { usePathname } from "next/navigation" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { useState, useEffect } from "react" @@ -10,6 +11,7 @@ import { WalletConnectModal } from "./WalletConnectModal" import { useAppContext } from "@/context/walletContext" import { ChessIcon, + DashboardIcon, WatchIcon, NewsIcon, UserIcon, @@ -35,6 +37,7 @@ export function GameSidebar({ const [collapsed, setLocalCollapsed] = useState(propCollapsed); const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); const { address, status } = useAppContext(); + const pathname = usePathname(); useEffect(() => { setCollapsed(collapsed); @@ -106,37 +109,49 @@ export function GameSidebar({ label="Play" href="/" collapsed={collapsed && !isHovered} - active + active={pathname === "/"} + /> + } + label="Dashboard" + href="/dashboard" + collapsed={collapsed && !isHovered} + active={pathname === "/dashboard"} /> } label="Watch" href="/watch" collapsed={collapsed && !isHovered} + active={pathname === "/watch"} /> } label="News" href="/news" collapsed={collapsed && !isHovered} + active={pathname === "/news"} /> } label="Profile" href="/profile" collapsed={collapsed && !isHovered} + active={pathname === "/profile"} /> } label="Settings" href="/settings" collapsed={collapsed && !isHovered} + active={pathname === "/settings"} /> } label="Support" href="/support" collapsed={collapsed && !isHovered} + active={pathname === "/support"} /> @@ -261,6 +276,7 @@ interface MobileSidebarProps { function MobileSidebar({ className = "" }: MobileSidebarProps) { const { address, status } = useAppContext(); const truncateAddress = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`; + const pathname = usePathname(); return (
@@ -275,12 +291,13 @@ function MobileSidebar({ className = "" }: MobileSidebarProps) {
{status === "connected" && address ? ( diff --git a/frontend/components/dashboard/EloChart.tsx b/frontend/components/dashboard/EloChart.tsx new file mode 100644 index 0000000..3b4d421 --- /dev/null +++ b/frontend/components/dashboard/EloChart.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { + Area, + AreaChart, + Brush, + CartesianGrid, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + type TooltipContentProps, +} from "recharts"; +import type { + NameType, + ValueType, +} from "recharts/types/component/DefaultTooltipContent"; +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; + +interface EloChartProps { + data: EloDataPoint[]; + title?: string; + height?: number; +} + +function formatDateTick(date: string): string { + return new Date(date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function CustomTooltip({ active, payload }: TooltipContentProps) { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload as EloDataPoint; + const positive = point.change >= 0; + + return ( +
+

+ {new Date(point.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+

+ Rating {point.elo} +

+

+ {positive ? "+" : ""}{point.change} pts +

+

vs {point.opponent}

+
+
+ ); +} + +export default function EloChart({ + data, + title = "Rating Momentum", + height = 360, +}: EloChartProps) { + if (!data.length) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + const currentElo = data[data.length - 1]?.elo ?? 0; + const startingElo = data[0]?.elo ?? 0; + const yValues = data.map((point) => point.elo); + const minElo = Math.min(...yValues); + const maxElo = Math.max(...yValues); + const padding = Math.max(18, Math.round((maxElo - minElo) * 0.18)); + const xTickStep = Math.max(1, Math.floor(data.length / 6)); + const xTicks = data.filter((_, index) => index % xTickStep === 0 || index === data.length - 1).map((point) => point.date); + const netChange = currentElo - startingElo; + + return ( +
+
+
+

{title}

+

+ Interactive rating history across the selected sample. +

+
+
= 0 + ? "inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-sm font-semibold text-emerald-400" + : "inline-flex items-center rounded-full border border-red-500/30 bg-red-500/10 px-3 py-1 text-sm font-semibold text-red-400"} + > + {netChange >= 0 ? "+" : ""}{netChange} overall +
+
+ + + + + + + + + + + + + + + + + + + + + + {data.length > 18 ? ( + + ) : null} + + +
+ ); +} diff --git a/frontend/components/dashboard/PerformanceBreakdown.tsx b/frontend/components/dashboard/PerformanceBreakdown.tsx new file mode 100644 index 0000000..7402f6f --- /dev/null +++ b/frontend/components/dashboard/PerformanceBreakdown.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useMemo } from "react"; +import { Flame, Swords, Trophy } from "lucide-react"; +import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"; +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; +import { cn } from "@/lib/utils"; + +interface PerformanceBreakdownProps { + data: EloDataPoint[]; + wins: number; + losses: number; + draws: number; + currentStreak: number; + bestStreak: number; +} + +const RESULT_COLORS = ["#34d399", "#f87171", "#facc15"]; +const OPENINGS = [ + "Sicilian Defense", + "Queen's Gambit", + "Ruy Lopez", + "French Defense", + "King's Indian", +] as const; + +export default function PerformanceBreakdown({ + data, + wins, + losses, + draws, + currentStreak, + bestStreak, +}: PerformanceBreakdownProps) { + const distribution = [ + { name: "Wins", value: wins }, + { name: "Losses", value: losses }, + { name: "Draws", value: draws }, + ]; + const totalGames = wins + losses + draws; + + const colorBreakdown = useMemo(() => { + const buckets = { + white: { label: "White", wins: 0, games: 0 }, + black: { label: "Black", wins: 0, games: 0 }, + }; + + data.forEach((point, index) => { + const bucket = index % 2 === 0 ? buckets.white : buckets.black; + bucket.games += 1; + if (point.change > 0) { + bucket.wins += 1; + } + }); + + return Object.values(buckets).map((bucket) => ({ + ...bucket, + winRate: bucket.games ? (bucket.wins / bucket.games) * 100 : 0, + })); + }, [data]); + + const openingBreakdown = useMemo(() => { + const buckets = OPENINGS.reduce>((acc, opening) => { + acc[opening] = { games: 0, wins: 0 }; + return acc; + }, {}); + + data.forEach((point, index) => { + const opening = OPENINGS[index % OPENINGS.length]; + buckets[opening].games += 1; + if (point.change > 0) { + buckets[opening].wins += 1; + } + }); + + return Object.entries(buckets) + .map(([opening, bucket]) => ({ + opening, + games: bucket.games, + winRate: bucket.games ? (bucket.wins / bucket.games) * 100 : 0, + })) + .sort((left, right) => right.winRate - left.winRate); + }, [data]); + + return ( +
+
+

Performance breakdown

+

Win distribution, streak momentum, color edge, and opening comfort.

+
+ +
+
+
+
+ + Win rate ring +
+
+
+ + + + {distribution.map((entry, index) => ( + + ))} + + + +
+
+ {distribution.map((entry, index) => ( +
+ + {entry.name} + {entry.value} + + {totalGames ? `${((entry.value / totalGames) * 100).toFixed(0)}%` : "0%"} + +
+ ))} +
+
+
+ +
+
+ + Streak info +
+
+
+

Current streak

+

0 ? "text-emerald-400" : currentStreak < 0 ? "text-red-400" : "text-gray-300", + )}> + {currentStreak > 0 ? `+${currentStreak}` : currentStreak} +

+

+ {currentStreak > 0 ? "Winning run active" : currentStreak < 0 ? "Pressure streak" : "No active streak"} +

+
+
+

Best streak

+

{bestStreak}

+

Longest win streak in this sample.

+
+
+
+
+ +
+
+
+ + Win rate by color +
+
+ {colorBreakdown.map((entry) => ( +
+
+ {entry.label} + {entry.winRate.toFixed(0)}% +
+
+
+
+
+ ))} +
+
+ +
+
+
+

Win rate by opening

+

Top lines based on deterministic mock repertoire.

+
+ + {openingBreakdown.length} lines + +
+
+ {openingBreakdown.map((entry) => ( +
+
+

{entry.opening}

+

{entry.games} games sampled

+
+
+

{entry.winRate.toFixed(0)}%

+

win rate

+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/frontend/components/dashboard/RankBadge.tsx b/frontend/components/dashboard/RankBadge.tsx new file mode 100644 index 0000000..09895cc --- /dev/null +++ b/frontend/components/dashboard/RankBadge.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Shield, Sparkles } from "lucide-react"; +import { getRankTier } from "@/lib/eloStatsUtils"; +import { cn } from "@/lib/utils"; + +interface RankBadgeProps { + elo: number; +} + +const tierStyles: Record = { + slate: { + shell: "from-slate-500/25 to-slate-300/10 border-slate-400/25 text-slate-100", + icon: "text-slate-300", + bar: "from-slate-400 to-slate-200", + }, + teal: { + shell: "from-teal-500/25 to-cyan-400/10 border-teal-400/25 text-teal-50", + icon: "text-teal-300", + bar: "from-teal-400 to-cyan-300", + }, + indigo: { + shell: "from-indigo-500/25 to-blue-400/10 border-indigo-400/25 text-indigo-50", + icon: "text-indigo-300", + bar: "from-indigo-400 to-blue-300", + }, + purple: { + shell: "from-purple-500/25 to-fuchsia-400/10 border-purple-400/25 text-purple-50", + icon: "text-purple-300", + bar: "from-purple-400 to-fuchsia-300", + }, + amber: { + shell: "from-amber-500/25 to-orange-400/10 border-amber-400/25 text-amber-50", + icon: "text-amber-300", + bar: "from-amber-400 to-orange-300", + }, + rose: { + shell: "from-rose-500/25 to-red-400/10 border-rose-400/25 text-rose-50", + icon: "text-rose-300", + bar: "from-rose-400 to-red-300", + }, +}; + +export default function RankBadge({ elo }: RankBadgeProps) { + const tier = getRankTier(elo); + const styles = tierStyles[tier.color] ?? tierStyles.indigo; + const isTopTier = !Number.isFinite(tier.maxElo); + const progress = isTopTier + ? 100 + : Math.min(100, Math.max(0, ((elo - tier.minElo) / (tier.maxElo - tier.minElo + 1)) * 100)); + const remaining = isTopTier ? 0 : Math.max(0, tier.maxElo + 1 - elo); + + return ( +
+
+
+
+
+ +
+
+

Rank tier

+

{tier.name}

+
+
+
+ + {elo} +
+
+ +
+
+ Progress + {isTopTier ? "Top tier" : `${remaining} pts to next`} +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/dashboard/RecentMatches.tsx b/frontend/components/dashboard/RecentMatches.tsx new file mode 100644 index 0000000..fd46ff2 --- /dev/null +++ b/frontend/components/dashboard/RecentMatches.tsx @@ -0,0 +1,102 @@ +"use client"; + +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; +import { cn } from "@/lib/utils"; + +interface RecentMatchesProps { + data: EloDataPoint[]; +} + +function getResult(change: number): "W" | "L" | "D" { + if (change > 0) { + return "W"; + } + + if (change < 0) { + return "L"; + } + + return "D"; +} + +function formatOpponent(opponent: string): string { + if (opponent.length <= 12) { + return opponent; + } + + return `${opponent.slice(0, 6)}...${opponent.slice(-4)}`; +} + +const badgeClassMap = { + W: "border-emerald-500/30 bg-emerald-500/15 text-emerald-400", + L: "border-red-500/30 bg-red-500/15 text-red-400", + D: "border-yellow-500/30 bg-yellow-500/15 text-yellow-400", +} as const; + +export default function RecentMatches({ data }: RecentMatchesProps) { + const matches = [...data].slice(-10).reverse(); + + return ( +
+
+
+

Recent matches

+

Last 10 rating events in the selected range.

+
+ + {matches.length} shown + +
+ +
+ + + + + + + + + + + + {matches.map((match, index) => { + const result = getResult(match.change); + + return ( + + + + + + + + ); + })} + +
DateOpponentResultΔ ELONew ELO
+ {new Date(match.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })} + {formatOpponent(match.opponent)} + + {result} + + = 0 ? "text-emerald-400" : "text-red-400")}> + {match.change >= 0 ? "+" : ""}{match.change} + {match.elo}
+
+
+ ); +} diff --git a/frontend/components/dashboard/StatCard.tsx b/frontend/components/dashboard/StatCard.tsx new file mode 100644 index 0000000..9e042b4 --- /dev/null +++ b/frontend/components/dashboard/StatCard.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ArrowDownRight, ArrowUpRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + icon: ReactNode; + label: string; + value: string | number; + trend?: { value: number; label: string }; + accentColor?: string; +} + +const accentStyles: Record = { + teal: { + icon: "bg-gradient-to-br from-teal-400/20 to-teal-500/10 text-teal-300", + ring: "border-teal-500/20", + glow: "from-teal-500/10 to-blue-500/5", + }, + blue: { + icon: "bg-gradient-to-br from-blue-400/20 to-indigo-500/10 text-blue-300", + ring: "border-blue-500/20", + glow: "from-blue-500/10 to-indigo-500/5", + }, + emerald: { + icon: "bg-gradient-to-br from-emerald-400/20 to-emerald-500/10 text-emerald-300", + ring: "border-emerald-500/20", + glow: "from-emerald-500/10 to-teal-500/5", + }, + amber: { + icon: "bg-gradient-to-br from-amber-400/20 to-orange-500/10 text-amber-300", + ring: "border-amber-500/20", + glow: "from-amber-500/10 to-orange-500/5", + }, + rose: { + icon: "bg-gradient-to-br from-rose-400/20 to-red-500/10 text-rose-300", + ring: "border-rose-500/20", + glow: "from-rose-500/10 to-red-500/5", + }, +}; + +export default function StatCard({ + icon, + label, + value, + trend, + accentColor = "teal", +}: StatCardProps) { + const accent = accentStyles[accentColor] ?? accentStyles.teal; + const trendPositive = (trend?.value ?? 0) >= 0; + + return ( +
+
{icon}
+
+

{label}

+
+

{value}

+ {trend ? ( +
+ {trendPositive ? : } + {trendPositive ? "+" : ""}{trend.value.toFixed(1)} + {trend.label} +
+ ) : null} +
+
+
+
+ ); +} diff --git a/frontend/components/icons.tsx b/frontend/components/icons.tsx index 531d9e1..c57fbc5 100644 --- a/frontend/components/icons.tsx +++ b/frontend/components/icons.tsx @@ -29,6 +29,21 @@ export function WatchIcon() { return } +export function DashboardIcon() { + return ( + + + + ); +} + export function NewsIcon() { return } diff --git a/frontend/constants/mockEloData.ts b/frontend/constants/mockEloData.ts new file mode 100644 index 0000000..3ca62a0 --- /dev/null +++ b/frontend/constants/mockEloData.ts @@ -0,0 +1,94 @@ +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; + +export const EXTENDED_MOCK_ELO_DATA: EloDataPoint[] = [ + { date: "2025-12-27", elo: 1112, opponent: "GA7M...Q1A1", change: +12 }, + { date: "2025-12-28", elo: 1100, opponent: "GB2N...R2B2", change: -12 }, + { date: "2025-12-29", elo: 1087, opponent: "GC3P...S3C3", change: -13 }, + { date: "2025-12-30", elo: 1106, opponent: "GD4Q...T4D4", change: +19 }, + { date: "2025-12-31", elo: 1125, opponent: "GE5R...U5E5", change: +19 }, + { date: "2026-01-01", elo: 1141, opponent: "GF6S...V6F6", change: +16 }, + { date: "2026-01-02", elo: 1124, opponent: "GG7T...W7G7", change: -17 }, + { date: "2026-01-03", elo: 1138, opponent: "GH8U...X8H8", change: +14 }, + { date: "2026-01-04", elo: 1120, opponent: "GI9V...Y9I9", change: -18 }, + { date: "2026-01-05", elo: 1108, opponent: "GJ1W...Z1J1", change: -12 }, + { date: "2026-01-06", elo: 1096, opponent: "GK2X...A2K2", change: -12 }, + { date: "2026-01-07", elo: 1079, opponent: "GL3Y...B3L3", change: -17 }, + { date: "2026-01-08", elo: 1066, opponent: "GM4Z...C4M4", change: -13 }, + { date: "2026-01-09", elo: 1085, opponent: "GN5A...D5N5", change: +19 }, + { date: "2026-01-10", elo: 1070, opponent: "GO6B...E6O6", change: -15 }, + { date: "2026-01-11", elo: 1085, opponent: "GA7M...Q1A1", change: +15 }, + { date: "2026-01-12", elo: 1072, opponent: "GB2N...R2B2", change: -13 }, + { date: "2026-01-13", elo: 1090, opponent: "GC3P...S3C3", change: +18 }, + { date: "2026-01-14", elo: 1107, opponent: "GD4Q...T4D4", change: +17 }, + { date: "2026-01-15", elo: 1121, opponent: "GE5R...U5E5", change: +14 }, + { date: "2026-01-16", elo: 1121, opponent: "GF6S...V6F6", change: +0 }, + { date: "2026-01-17", elo: 1104, opponent: "GG7T...W7G7", change: -17 }, + { date: "2026-01-18", elo: 1123, opponent: "GH8U...X8H8", change: +19 }, + { date: "2026-01-19", elo: 1138, opponent: "GI9V...Y9I9", change: +15 }, + { date: "2026-01-20", elo: 1158, opponent: "GJ1W...Z1J1", change: +20 }, + { date: "2026-01-21", elo: 1178, opponent: "GK2X...A2K2", change: +20 }, + { date: "2026-01-22", elo: 1166, opponent: "GL3Y...B3L3", change: -12 }, + { date: "2026-01-23", elo: 1152, opponent: "GM4Z...C4M4", change: -14 }, + { date: "2026-01-24", elo: 1166, opponent: "GN5A...D5N5", change: +14 }, + { date: "2026-01-25", elo: 1178, opponent: "GO6B...E6O6", change: +12 }, + { date: "2026-01-26", elo: 1190, opponent: "GA7M...Q1A1", change: +12 }, + { date: "2026-01-27", elo: 1210, opponent: "GB2N...R2B2", change: +20 }, + { date: "2026-01-28", elo: 1195, opponent: "GC3P...S3C3", change: -15 }, + { date: "2026-01-29", elo: 1180, opponent: "GD4Q...T4D4", change: -15 }, + { date: "2026-01-30", elo: 1180, opponent: "GE5R...U5E5", change: +0 }, + { date: "2026-01-31", elo: 1180, opponent: "GF6S...V6F6", change: +0 }, + { date: "2026-02-01", elo: 1193, opponent: "GG7T...W7G7", change: +13 }, + { date: "2026-02-02", elo: 1205, opponent: "GH8U...X8H8", change: +12 }, + { date: "2026-02-03", elo: 1220, opponent: "GI9V...Y9I9", change: +15 }, + { date: "2026-02-04", elo: 1237, opponent: "GJ1W...Z1J1", change: +17 }, + { date: "2026-02-05", elo: 1220, opponent: "GK2X...A2K2", change: -17 }, + { date: "2026-02-06", elo: 1235, opponent: "GL3Y...B3L3", change: +15 }, + { date: "2026-02-07", elo: 1247, opponent: "GM4Z...C4M4", change: +12 }, + { date: "2026-02-08", elo: 1264, opponent: "GN5A...D5N5", change: +17 }, + { date: "2026-02-09", elo: 1276, opponent: "GO6B...E6O6", change: +12 }, + { date: "2026-02-10", elo: 1288, opponent: "GA7M...Q1A1", change: +12 }, + { date: "2026-02-11", elo: 1304, opponent: "GB2N...R2B2", change: +16 }, + { date: "2026-02-12", elo: 1320, opponent: "GC3P...S3C3", change: +16 }, + { date: "2026-02-13", elo: 1333, opponent: "GD4Q...T4D4", change: +13 }, + { date: "2026-02-14", elo: 1349, opponent: "GE5R...U5E5", change: +16 }, + { date: "2026-02-15", elo: 1365, opponent: "GF6S...V6F6", change: +16 }, + { date: "2026-02-16", elo: 1345, opponent: "GG7T...W7G7", change: -20 }, + { date: "2026-02-17", elo: 1361, opponent: "GH8U...X8H8", change: +16 }, + { date: "2026-02-18", elo: 1376, opponent: "GI9V...Y9I9", change: +15 }, + { date: "2026-02-19", elo: 1357, opponent: "GJ1W...Z1J1", change: -19 }, + { date: "2026-02-20", elo: 1345, opponent: "GK2X...A2K2", change: -12 }, + { date: "2026-02-21", elo: 1333, opponent: "GL3Y...B3L3", change: -12 }, + { date: "2026-02-22", elo: 1319, opponent: "GM4Z...C4M4", change: -14 }, + { date: "2026-02-23", elo: 1334, opponent: "GN5A...D5N5", change: +15 }, + { date: "2026-02-24", elo: 1354, opponent: "GO6B...E6O6", change: +20 }, + { date: "2026-02-25", elo: 1366, opponent: "GA7M...Q1A1", change: +12 }, + { date: "2026-02-26", elo: 1353, opponent: "GB2N...R2B2", change: -13 }, + { date: "2026-02-27", elo: 1373, opponent: "GC3P...S3C3", change: +20 }, + { date: "2026-02-28", elo: 1360, opponent: "GD4Q...T4D4", change: -13 }, + { date: "2026-03-01", elo: 1378, opponent: "GE5R...U5E5", change: +18 }, + { date: "2026-03-02", elo: 1393, opponent: "GF6S...V6F6", change: +15 }, + { date: "2026-03-03", elo: 1412, opponent: "GG7T...W7G7", change: +19 }, + { date: "2026-03-04", elo: 1424, opponent: "GH8U...X8H8", change: +12 }, + { date: "2026-03-05", elo: 1408, opponent: "GI9V...Y9I9", change: -16 }, + { date: "2026-03-06", elo: 1426, opponent: "GJ1W...Z1J1", change: +18 }, + { date: "2026-03-07", elo: 1446, opponent: "GK2X...A2K2", change: +20 }, + { date: "2026-03-08", elo: 1458, opponent: "GL3Y...B3L3", change: +12 }, + { date: "2026-03-09", elo: 1478, opponent: "GM4Z...C4M4", change: +20 }, + { date: "2026-03-10", elo: 1466, opponent: "GN5A...D5N5", change: -12 }, + { date: "2026-03-11", elo: 1453, opponent: "GO6B...E6O6", change: -13 }, + { date: "2026-03-12", elo: 1470, opponent: "GA7M...Q1A1", change: +17 }, + { date: "2026-03-13", elo: 1451, opponent: "GB2N...R2B2", change: -19 }, + { date: "2026-03-14", elo: 1432, opponent: "GC3P...S3C3", change: -19 }, + { date: "2026-03-15", elo: 1449, opponent: "GD4Q...T4D4", change: +17 }, + { date: "2026-03-16", elo: 1469, opponent: "GE5R...U5E5", change: +20 }, + { date: "2026-03-17", elo: 1486, opponent: "GF6S...V6F6", change: +17 }, + { date: "2026-03-18", elo: 1504, opponent: "GG7T...W7G7", change: +18 }, + { date: "2026-03-19", elo: 1520, opponent: "GH8U...X8H8", change: +16 }, + { date: "2026-03-20", elo: 1537, opponent: "GI9V...Y9I9", change: +17 }, + { date: "2026-03-21", elo: 1552, opponent: "GJ1W...Z1J1", change: +15 }, + { date: "2026-03-22", elo: 1565, opponent: "GK2X...A2K2", change: +13 }, + { date: "2026-03-23", elo: 1548, opponent: "GL3Y...B3L3", change: -17 }, + { date: "2026-03-24", elo: 1564, opponent: "GM4Z...C4M4", change: +16 }, + { date: "2026-03-25", elo: 1584, opponent: "GN5A...D5N5", change: +20 }, + { date: "2026-03-26", elo: 1566, opponent: "GO6B...E6O6", change: -18 }, +]; diff --git a/frontend/hook/useEloStats.ts b/frontend/hook/useEloStats.ts new file mode 100644 index 0000000..d390d21 --- /dev/null +++ b/frontend/hook/useEloStats.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useMemo } from "react"; +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; +import { computeEloStats, filterByTimeRange } from "@/lib/eloStatsUtils"; + +export type TimeRange = "7d" | "30d" | "90d" | "all"; + +export interface EloStats { + currentElo: number; + peakElo: number; + lowestElo: number; + totalGames: number; + wins: number; + losses: number; + draws: number; + winRate: number; + currentStreak: number; + bestStreak: number; + avgChange: number; + volatility: number; + filteredData: EloDataPoint[]; +} + +export function useEloStats(data: EloDataPoint[], range: TimeRange): EloStats { + return useMemo(() => { + const filteredData = filterByTimeRange(data, range); + + return { + ...computeEloStats(filteredData), + filteredData, + }; + }, [data, range]); +} diff --git a/frontend/lib/eloStatsUtils.ts b/frontend/lib/eloStatsUtils.ts new file mode 100644 index 0000000..b38c5f7 --- /dev/null +++ b/frontend/lib/eloStatsUtils.ts @@ -0,0 +1,145 @@ +import type { EloDataPoint } from "@/components/profile/EloRatingChart"; +import type { EloStats, TimeRange } from "@/hook/useEloStats"; + +export function filterByTimeRange(data: EloDataPoint[], range: TimeRange): EloDataPoint[] { + if (!data.length || range === "all") { + return data; + } + + const now = new Date(data[data.length - 1].date); + const cutoff = new Date(now); + + switch (range) { + case "7d": + cutoff.setDate(cutoff.getDate() - 7); + break; + case "30d": + cutoff.setDate(cutoff.getDate() - 30); + break; + case "90d": + cutoff.setDate(cutoff.getDate() - 90); + break; + } + + return data.filter((point) => new Date(point.date) >= cutoff); +} + +export function computeStreak(data: EloDataPoint[]): { current: number; best: number } { + if (!data.length) { + return { current: 0, best: 0 }; + } + + let best = 0; + let currentWinRun = 0; + + for (const point of data) { + if (point.change > 0) { + currentWinRun += 1; + best = Math.max(best, currentWinRun); + } else { + currentWinRun = 0; + } + } + + const lastChange = data[data.length - 1]?.change ?? 0; + + if (lastChange === 0) { + return { current: 0, best }; + } + + const direction = lastChange > 0 ? 1 : -1; + let current = 0; + + for (let index = data.length - 1; index >= 0; index -= 1) { + const sign = Math.sign(data[index].change); + + if (sign !== direction) { + break; + } + + current += 1; + } + + return { + current: direction > 0 ? current : -current, + best, + }; +} + +export function computeVolatility(data: EloDataPoint[]): number { + if (!data.length) { + return 0; + } + + const avgChange = data.reduce((sum, point) => sum + point.change, 0) / data.length; + const variance = data.reduce((sum, point) => sum + (point.change - avgChange) ** 2, 0) / data.length; + + return Math.sqrt(variance); +} + +export function computeEloStats(data: EloDataPoint[]): Omit { + if (!data.length) { + return { + currentElo: 0, + peakElo: 0, + lowestElo: 0, + totalGames: 0, + wins: 0, + losses: 0, + draws: 0, + winRate: 0, + currentStreak: 0, + bestStreak: 0, + avgChange: 0, + volatility: 0, + }; + } + + const wins = data.filter((point) => point.change > 0).length; + const losses = data.filter((point) => point.change < 0).length; + const draws = data.length - wins - losses; + const totalGames = data.length; + const currentElo = data[data.length - 1]?.elo ?? 0; + const elos = data.map((point) => point.elo); + const { current, best } = computeStreak(data); + const avgChange = data.reduce((sum, point) => sum + point.change, 0) / totalGames; + + return { + currentElo, + peakElo: Math.max(...elos), + lowestElo: Math.min(...elos), + totalGames, + wins, + losses, + draws, + winRate: totalGames ? (wins / totalGames) * 100 : 0, + currentStreak: current, + bestStreak: best, + avgChange, + volatility: computeVolatility(data), + }; +} + +export function getRankTier(elo: number): { name: string; color: string; minElo: number; maxElo: number } { + if (elo < 1000) { + return { name: "Beginner", color: "slate", minElo: 0, maxElo: 999 }; + } + + if (elo < 1200) { + return { name: "Intermediate", color: "teal", minElo: 1000, maxElo: 1199 }; + } + + if (elo < 1400) { + return { name: "Advanced", color: "indigo", minElo: 1200, maxElo: 1399 }; + } + + if (elo < 1600) { + return { name: "Expert", color: "purple", minElo: 1400, maxElo: 1599 }; + } + + if (elo < 1800) { + return { name: "Master", color: "amber", minElo: 1600, maxElo: 1799 }; + } + + return { name: "Grandmaster", color: "rose", minElo: 1800, maxElo: Number.POSITIVE_INFINITY }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9ee289..ec1a527 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@eslint/eslintrc": "^3", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4", + "@testing-library/react": "^16.3.2", "@types/chess.js": "^0.13.7", "@types/node": "^20", "@types/react": "^19", @@ -42,8 +43,10 @@ "@types/webpack": "^5.28.5", "eslint": "^9", "eslint-config-next": "15.2.3", + "jsdom": "^29.0.2", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } }, "node_modules/@alloc/quick-lru": { @@ -73,6 +76,78 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -104,6 +179,7 @@ "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1826,32 +1902,161 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", - "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.1", - "tslib": "^2.4.0" + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1997,6 +2202,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2471,9 +2694,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2693,6 +2916,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", @@ -3042,98 +3275,392 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "license": "MIT" }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", - "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@stellar/freighter-api": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-1.7.1.tgz", - "integrity": "sha512-XvPO+XgEbkeP0VhP0U1edOkds+rGS28+y8GRGbCVXeZ9ZslbWqRFQoETAdX8IXGuykk2ib/aPokiLc5ZaWYP7w==", - "license": "Apache-2.0" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=14" - }, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", + "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@stellar/freighter-api": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-1.7.1.tgz", + "integrity": "sha512-XvPO+XgEbkeP0VhP0U1edOkds+rGS28+y8GRGbCVXeZ9ZslbWqRFQoETAdX8IXGuykk2ib/aPokiLc5ZaWYP7w==", + "license": "Apache-2.0" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/gregberge" @@ -3243,6 +3770,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3597,27 +4125,103 @@ "tailwindcss": "4.0.15" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, "engines": { - "node": ">=10.13.0" + "node": ">=18" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "MIT", - "optional": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chess.js": { "version": "0.13.7", "resolved": "https://registry.npmjs.org/@types/chess.js/-/chess.js-0.13.7.tgz", @@ -3688,6 +4292,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3742,6 +4353,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.26.tgz", "integrity": "sha512-x9T6TLS76RIBGB0X81k+9697cNZel+f/v+BR8gzKNqISC3MhHHWoHY6XIEDY0E8psIJmCEMXqxjw7Np1u/mysA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -3760,6 +4372,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3770,6 +4383,7 @@ "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -3843,6 +4457,7 @@ "integrity": "sha512-XGwIabPallYipmcOk45DpsBSgLC64A0yvdAkrwEzwZ2viqGqRUJ8eEYoPz0CWnutgAFbNMPdsGGvzjSmcWVlEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.27.0", "@typescript-eslint/types": "8.27.0", @@ -4200,6 +4815,92 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -4381,6 +5082,7 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4457,6 +5159,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4666,6 +5378,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4825,6 +5547,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bignumber.js": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-4.1.0.tgz", @@ -4885,6 +5617,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -5030,6 +5763,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5455,6 +6198,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5527,6 +6284,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -5591,6 +6355,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -5650,6 +6424,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6025,6 +6806,7 @@ "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -6199,6 +6981,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -6435,6 +7218,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6470,6 +7263,16 @@ "node": ">=0.12.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6646,6 +7449,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6945,6 +7763,19 @@ "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==", "license": "BSD-3-Clause" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -7300,6 +8131,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7546,6 +8384,105 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7689,6 +8626,27 @@ "lightningcss-win32-x64-msvc": "1.29.2" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-darwin-arm64": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", @@ -8034,6 +8992,26 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8423,6 +9401,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8523,6 +9512,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8560,6 +9575,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8589,9 +9611,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -8609,7 +9631,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8627,6 +9649,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/primereact": { "version": "10.9.5", "resolved": "https://registry.npmjs.org/primereact/-/primereact-10.9.5.tgz", @@ -8706,6 +9763,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8808,6 +9866,7 @@ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.6.0.tgz", "integrity": "sha512-2KHNpeg2SyaxXYq+xO1TM+tOtN9hViI41otJuiYiu6DRYGw+WMvDFDMP4aw7zIKRRm1xd0gizXuKWhb8iJYHBw==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "disposables": "^1.0.1", "dnd-core": "^2.6.0", @@ -8898,6 +9957,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8918,7 +9978,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-remove-scroll": { "version": "2.6.3", @@ -9076,6 +10137,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9098,7 +10160,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -9304,6 +10367,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, "node_modules/rspack-resolver": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/rspack-resolver/-/rspack-resolver-1.2.2.tgz", @@ -9426,6 +10523,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", @@ -9458,6 +10568,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9725,6 +10836,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -9812,6 +10930,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stellar-base": { "version": "8.0.1-soroban.4", "resolved": "https://registry.npmjs.org/stellar-base/-/stellar-base-8.0.1-soroban.4.tgz", @@ -10116,6 +11248,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", @@ -10130,7 +11269,8 @@ "version": "4.0.15", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz", "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -10218,15 +11358,32 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10236,11 +11393,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -10251,11 +11411,12 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10263,6 +11424,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -10296,6 +11487,32 @@ "integrity": "sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10439,6 +11656,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10466,6 +11684,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -10646,6 +11874,475 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vitest/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", @@ -10660,6 +12357,16 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webpack": { "version": "5.99.7", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.7.tgz", @@ -10742,6 +12449,31 @@ "node": ">=4.0" } }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10846,6 +12578,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10856,6 +12605,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 337e551..3f18179 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.11", @@ -36,6 +37,7 @@ "@eslint/eslintrc": "^3", "@svgr/webpack": "^8.1.0", "@tailwindcss/postcss": "^4", + "@testing-library/react": "^16.3.2", "@types/chess.js": "^0.13.7", "@types/node": "^20", "@types/react": "^19", @@ -43,7 +45,9 @@ "@types/webpack": "^5.28.5", "eslint": "^9", "eslint-config-next": "15.2.3", + "jsdom": "^29.0.2", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.5" } } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index ab96508..24656f0 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,6 +22,9 @@ "name": "next" } ], + "types": [ + "vitest/globals" + ], "paths": { "@/*": [ "./*" @@ -37,4 +40,4 @@ "exclude": [ "node_modules" ] -} \ No newline at end of file +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..5aea895 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); From ef87c5f23b7939803e37e88e40cf30608963be1a Mon Sep 17 00:00:00 2001 From: Ugasutun <141843972+Ugasutun@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:30:18 +0100 Subject: [PATCH 2/2] Implement live match spectator mode at /watch. (#2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- frontend/__tests__/spectatorUtils.test.ts | 59 +++ frontend/__tests__/useSpectatorSocket.test.ts | 96 +++++ frontend/app/watch/page.tsx | 194 +++++++++ frontend/components/watch/LiveGameCard.tsx | 98 +++++ frontend/components/watch/PlayerPanel.tsx | 85 ++++ frontend/components/watch/SpectatorBoard.tsx | 274 ++++++++++++ .../components/watch/SpectatorMoveList.tsx | 103 +++++ frontend/constants/mockLiveGames.ts | 168 ++++++++ frontend/hook/useSpectatorSocket.ts | 401 ++++++++++++++++++ frontend/lib/spectatorUtils.ts | 90 ++++ 10 files changed, 1568 insertions(+) create mode 100644 frontend/__tests__/spectatorUtils.test.ts create mode 100644 frontend/__tests__/useSpectatorSocket.test.ts create mode 100644 frontend/app/watch/page.tsx create mode 100644 frontend/components/watch/LiveGameCard.tsx create mode 100644 frontend/components/watch/PlayerPanel.tsx create mode 100644 frontend/components/watch/SpectatorBoard.tsx create mode 100644 frontend/components/watch/SpectatorMoveList.tsx create mode 100644 frontend/constants/mockLiveGames.ts create mode 100644 frontend/hook/useSpectatorSocket.ts create mode 100644 frontend/lib/spectatorUtils.ts diff --git a/frontend/__tests__/spectatorUtils.test.ts b/frontend/__tests__/spectatorUtils.test.ts new file mode 100644 index 0000000..f84ae4e --- /dev/null +++ b/frontend/__tests__/spectatorUtils.test.ts @@ -0,0 +1,59 @@ +import type { SpectatorMove } from "@/hook/useSpectatorSocket"; +import { + formatClock, + getCapturedFromMoves, + getGamePhase, + truncateAddress, +} from "@/lib/spectatorUtils"; + +describe("spectatorUtils", () => { + describe("formatClock", () => { + it("formats seconds into mm:ss", () => { + expect(formatClock(0)).toBe("0:00"); + expect(formatClock(60)).toBe("1:00"); + expect(formatClock(599)).toBe("9:59"); + expect(formatClock(3600)).toBe("60:00"); + }); + }); + + describe("truncateAddress", () => { + it("truncates stellar addresses", () => { + expect(truncateAddress("GABCDEFGHIJKLMNOP")).toBe("GABCDE...LMNOP"); + }); + + it("returns short addresses unchanged", () => { + expect(truncateAddress("GSHORT123")).toBe("GSHORT123"); + }); + }); + + describe("getCapturedFromMoves", () => { + it("tracks captured pieces for both colors", () => { + const moves: SpectatorMove[] = [ + { from: "e2", to: "e4", san: "e4", color: "w" }, + { from: "d7", to: "d5", san: "d5", color: "b" }, + { from: "e4", to: "d5", san: "exd5", color: "w" }, + { from: "d8", to: "d5", san: "Qxd5", color: "b" }, + { from: "b1", to: "c3", san: "Nc3", color: "w" }, + ]; + + expect(getCapturedFromMoves(moves)).toEqual({ + white: ["p"], + black: ["p"], + }); + }); + + it("handles empty move lists", () => { + expect(getCapturedFromMoves([])).toEqual({ white: [], black: [] }); + }); + }); + + describe("getGamePhase", () => { + it("returns the correct phase by move count", () => { + expect(getGamePhase(0)).toBe("opening"); + expect(getGamePhase(10)).toBe("opening"); + expect(getGamePhase(11)).toBe("middlegame"); + expect(getGamePhase(30)).toBe("middlegame"); + expect(getGamePhase(31)).toBe("endgame"); + }); + }); +}); diff --git a/frontend/__tests__/useSpectatorSocket.test.ts b/frontend/__tests__/useSpectatorSocket.test.ts new file mode 100644 index 0000000..1cc8cc1 --- /dev/null +++ b/frontend/__tests__/useSpectatorSocket.test.ts @@ -0,0 +1,96 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useSpectatorSocket } from "@/hook/useSpectatorSocket"; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static OPEN = 1; + static CONNECTING = 0; + static CLOSED = 3; + + url: string; + readyState = MockWebSocket.CONNECTING; + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + send = vi.fn(); + + close(code = 1000, reason = "") { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code, reason }); + } + + emitOpen() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + } + + emitMessage(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) }); + } +} + +describe("useSpectatorSocket", () => { + const originalWebSocket = globalThis.WebSocket; + + beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal("WebSocket", MockWebSocket as unknown as typeof WebSocket); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + globalThis.WebSocket = originalWebSocket; + }); + + it("initializes with idle status and null game state when no game is selected", () => { + const { result } = renderHook(() => useSpectatorSocket(null)); + + expect(result.current.status).toBe("idle"); + expect(result.current.gameState).toBeNull(); + }); + + it("exposes disconnect and reconnect callbacks", () => { + const { result } = renderHook(() => useSpectatorSocket(null)); + + expect(typeof result.current.disconnect).toBe("function"); + expect(typeof result.current.reconnect).toBe("function"); + }); + + it("stores sync state for live websocket games", async () => { + const { result } = renderHook(() => useSpectatorSocket("live-game-42")); + + await waitFor(() => { + expect(MockWebSocket.instances.length).toBeGreaterThan(0); + }); + + act(() => { + for (const socket of MockWebSocket.instances) { + socket.emitOpen(); + socket.emitMessage({ + type: "sync", + fen: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", + moves: [{ from: "e2", to: "e4", san: "e4", color: "w" }], + whiteTime: 600, + blackTime: 600, + status: "playing", + spectatorCount: 9, + white: { address: "GWHITE123456", elo: 1300 }, + black: { address: "GBLACK654321", elo: 1290 }, + }); + } + }); + + await waitFor(() => { + expect(MockWebSocket.instances.at(-1)?.url).toContain("/v1/games/live-game-42/spectate"); + expect(result.current.gameState?.fen).toContain("4P3"); + expect(result.current.gameState?.spectatorCount).toBe(9); + }); + }); +}); diff --git a/frontend/app/watch/page.tsx b/frontend/app/watch/page.tsx new file mode 100644 index 0000000..d49c0ce --- /dev/null +++ b/frontend/app/watch/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Eye, Radio, SearchX } from "lucide-react"; +import { LiveGameCard } from "@/components/watch/LiveGameCard"; +import { SpectatorBoard } from "@/components/watch/SpectatorBoard"; +import { + MOCK_LIVE_GAMES, + type LiveGameMode, + type LiveGameSummary, + type MockLiveGameSummary, +} from "@/constants/mockLiveGames"; +import { cn } from "@/lib/utils"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +const FILTERS: Array<{ label: string; value: "all" | LiveGameMode }> = [ + { label: "All", value: "all" }, + { label: "Rated", value: "rated" }, + { label: "Casual", value: "casual" }, +]; + +type LiveGameRecord = LiveGameSummary & { + mode: LiveGameMode; +}; + +function normalizeLiveGame(game: Partial, index: number): LiveGameRecord | null { + if (!game.gameId || !game.white?.address || !game.black?.address) { + return null; + } + + return { + gameId: game.gameId, + white: { + address: game.white.address, + elo: game.white.elo ?? 1200, + }, + black: { + address: game.black.address, + elo: game.black.elo ?? 1200, + }, + moveCount: game.moveCount ?? 0, + spectatorCount: game.spectatorCount ?? 0, + timeControl: game.timeControl ?? "10+0", + status: game.status === "ending_soon" ? "ending_soon" : "playing", + mode: + game.mode ?? + ((game as { rated?: boolean }).rated === true + ? "rated" + : (game as { rated?: boolean }).rated === false + ? "casual" + : index % 2 === 0 + ? "rated" + : "casual"), + }; +} + +export default function WatchPage() { + const [selectedGameId, setSelectedGameId] = useState(null); + const [filter, setFilter] = useState<"all" | LiveGameMode>("all"); + const [liveGames, setLiveGames] = useState(MOCK_LIVE_GAMES); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + const fetchLiveGames = async () => { + try { + const response = await fetch(`${API_BASE}/v1/games/live`, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const payload = (await response.json()) as Array>; + const normalizedGames = payload + .map((game, index) => normalizeLiveGame(game, index)) + .filter((game): game is LiveGameRecord => game !== null); + + if (active) { + setLiveGames(normalizedGames.length > 0 ? normalizedGames : MOCK_LIVE_GAMES); + } + } catch { + if (active) { + // Real API integration will replace this mock fallback when the live lobby endpoint is available. + setLiveGames(MOCK_LIVE_GAMES as MockLiveGameSummary[]); + } + } finally { + if (active) { + setLoading(false); + } + } + }; + + fetchLiveGames(); + const intervalId = window.setInterval(fetchLiveGames, 10000); + + return () => { + active = false; + clearInterval(intervalId); + }; + }, []); + + const filteredGames = useMemo(() => { + if (filter === "all") { + return liveGames; + } + + return liveGames.filter((game) => game.mode === filter); + }, [filter, liveGames]); + + const totalViewers = useMemo( + () => liveGames.reduce((sum, game) => sum + game.spectatorCount, 0), + [liveGames], + ); + + if (selectedGameId) { + return setSelectedGameId(null)} />; + } + + return ( +
+
+
+
+
+ + Live spectator network +
+

Live Games

+

+ {totalViewers} viewers across {liveGames.length} active games +

+
+ +
+ {FILTERS.map((option) => ( + + ))} +
+
+
+ + {filteredGames.length === 0 && !loading ? ( +
+
+ +
+

No live games right now

+

+ Check back in a few moments. New matches will appear here automatically. +

+
+ ) : ( +
+ {filteredGames.map((game) => ( + + ))} +
+ )} + +
+ + {loading ? "Refreshing live lobby…" : "Live list auto-refreshes every 10 seconds"} +
+
+ ); +} diff --git a/frontend/components/watch/LiveGameCard.tsx b/frontend/components/watch/LiveGameCard.tsx new file mode 100644 index 0000000..de492b7 --- /dev/null +++ b/frontend/components/watch/LiveGameCard.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Eye, Flame, Swords } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { truncateAddress } from "@/lib/spectatorUtils"; + +interface LiveGameCardProps { + gameId: string; + white: { address: string; elo: number }; + black: { address: string; elo: number }; + moveCount: number; + spectatorCount: number; + timeControl: string; + status: "playing" | "ending_soon"; + onWatch: (gameId: string) => void; +} + +export function LiveGameCard({ + gameId, + white, + black, + moveCount, + spectatorCount, + timeControl, + status, + onWatch, +}: LiveGameCardProps) { + const isEndingSoon = status === "ending_soon"; + + return ( + + ); +} diff --git a/frontend/components/watch/PlayerPanel.tsx b/frontend/components/watch/PlayerPanel.tsx new file mode 100644 index 0000000..e0b6c35 --- /dev/null +++ b/frontend/components/watch/PlayerPanel.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Clock3, Trophy } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + formatClock, + getCapturedPieceSymbols, + truncateAddress, +} from "@/lib/spectatorUtils"; + +interface PlayerPanelProps { + address: string; + elo: number; + timeRemaining: number; + isActive: boolean; + color: "white" | "black"; + capturedPieces: string[]; +} + +export function PlayerPanel({ + address, + elo, + timeRemaining, + isActive, + color, + capturedPieces, +}: PlayerPanelProps) { + const capturedSymbols = getCapturedPieceSymbols(capturedPieces, color); + const lowTime = timeRemaining < 30; + + return ( +
+
+
+
+ {address.slice(0, 2).toUpperCase()} +
+
+
+

{truncateAddress(address)}

+ + + {elo} + +
+

{color}

+
+
+ +
+ + {formatClock(timeRemaining)} +
+
+ +
+ Captured +
+ {capturedSymbols.length > 0 ? ( + capturedSymbols.map((piece, index) => {piece}) + ) : ( + No captures yet + )} +
+
+
+ ); +} diff --git a/frontend/components/watch/SpectatorBoard.tsx b/frontend/components/watch/SpectatorBoard.tsx new file mode 100644 index 0000000..bfe5d8a --- /dev/null +++ b/frontend/components/watch/SpectatorBoard.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { Chess } from "chess.js"; +import { AlertTriangle, Eye, LoaderCircle, LogOut, RefreshCcw, Signal } from "lucide-react"; +import { PlayerPanel } from "@/components/watch/PlayerPanel"; +import { SpectatorMoveList } from "@/components/watch/SpectatorMoveList"; +import { useSpectatorSocket } from "@/hook/useSpectatorSocket"; +import { getGamePhase, getCapturedFromMoves } from "@/lib/spectatorUtils"; +import { cn } from "@/lib/utils"; + +const ChessboardComponent = dynamic( + () => import("@/components/chess/ChessboardComponent"), + { + ssr: false, + loading: () => ( +
+
+ {Array.from({ length: 64 }).map((_, i) => ( +
+ ))} +
+
+ ), + }, +); + +interface SpectatorBoardProps { + gameId: string; + onLeave: () => void; +} + +function getStatusClasses(status: string) { + switch (status) { + case "connected": + return "border-emerald-500/30 bg-emerald-500/15 text-emerald-400"; + case "reconnecting": + case "connecting": + return "border-amber-500/30 bg-amber-500/15 text-amber-300"; + case "error": + return "border-red-500/30 bg-red-500/15 text-red-400"; + default: + return "border-gray-700/40 bg-gray-700/20 text-gray-300"; + } +} + +function getGameOverText(status: string, result?: string) { + switch (status) { + case "checkmate": + return { + title: "Checkmate", + subtitle: result ? `Result ${result}` : "The king has fallen.", + }; + case "stalemate": + return { + title: "Stalemate", + subtitle: result ? `Result ${result}` : "No legal moves remain.", + }; + case "draw": + return { + title: "Draw", + subtitle: result ? `Result ${result}` : "Players split the point.", + }; + case "resigned": + return { + title: "Resignation", + subtitle: result ? `Result ${result}` : "A player has resigned.", + }; + default: + return null; + } +} + +export function SpectatorBoard({ gameId, onLeave }: SpectatorBoardProps) { + const { status, gameState, disconnect, reconnect } = useSpectatorSocket(gameId); + const [displayClocks, setDisplayClocks] = useState({ white: 0, black: 0 }); + + useEffect(() => { + if (!gameState) { + setDisplayClocks({ white: 0, black: 0 }); + return; + } + + setDisplayClocks({ + white: gameState.whiteTime, + black: gameState.blackTime, + }); + }, [gameState?.whiteTime, gameState?.blackTime, gameState]); + + const activeColor = useMemo(() => { + if (!gameState || gameState.status !== "playing") { + return null; + } + + try { + return new Chess(gameState.fen).turn() === "w" ? "white" : "black"; + } catch { + return null; + } + }, [gameState]); + + useEffect(() => { + if (!gameState || gameState.status !== "playing" || !activeColor) { + return; + } + + const intervalId = window.setInterval(() => { + setDisplayClocks((previousClocks) => { + if (activeColor === "white") { + return { + white: Math.max(0, previousClocks.white - 1), + black: previousClocks.black, + }; + } + + return { + white: previousClocks.white, + black: Math.max(0, previousClocks.black - 1), + }; + }); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [activeColor, gameState]); + + const capturedPieces = useMemo( + () => getCapturedFromMoves(gameState?.moves ?? []), + [gameState?.moves], + ); + + const gameOverCopy = getGameOverText(gameState?.status ?? "playing", gameState?.result); + const moveCount = gameState?.moves.length ?? 0; + const gamePhase = getGamePhase(moveCount); + + const handleLeave = () => { + disconnect(); + onLeave(); + }; + + return ( +
+
+
+

Spectator Mode

+

Watching game {gameId}

+
+
+
+ {status === "connecting" || status === "reconnecting" ? ( + + ) : ( + + )} + {status} +
+
+ + {gameState?.spectatorCount ?? 0} spectators +
+ +
+
+ +
+
+ + +
+
+ false} /> +
+ + {status === "reconnecting" && ( +
+
+ + Reconnecting to live feed... +
+
+ )} + + {status === "error" && ( +
+
+ +

Spectator feed unavailable

+

Try reconnecting to restore the live board.

+ +
+
+ )} + + {gameOverCopy && ( +
+
+

Game complete

+

{gameOverCopy.title}

+

{gameOverCopy.subtitle}

+
+
+ )} +
+ + +
+ +
+
+

Game Info

+
+
+

Phase

+

{gamePhase}

+
+
+

Move Count

+

{moveCount} half-moves

+
+
+

Result

+

{gameState?.result ?? "In Progress"}

+
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/components/watch/SpectatorMoveList.tsx b/frontend/components/watch/SpectatorMoveList.tsx new file mode 100644 index 0000000..3e81526 --- /dev/null +++ b/frontend/components/watch/SpectatorMoveList.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { Chess } from "chess.js"; +import type { SpectatorMove } from "@/hook/useSpectatorSocket"; +import { cn } from "@/lib/utils"; + +interface SpectatorMoveListProps { + moves: SpectatorMove[]; + currentFen: string; +} + +export function SpectatorMoveList({ moves, currentFen }: SpectatorMoveListProps) { + const endRef = useRef(null); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [moves]); + + const groupedMoves = useMemo(() => { + const rows: Array<{ moveNumber: number; white?: SpectatorMove; black?: SpectatorMove }> = []; + + for (let index = 0; index < moves.length; index += 2) { + rows.push({ + moveNumber: Math.floor(index / 2) + 1, + white: moves[index], + black: moves[index + 1], + }); + } + + return rows; + }, [moves]); + + const sideToMove = useMemo(() => { + try { + return new Chess(currentFen).turn() === "w" ? "White" : "Black"; + } catch { + return "White"; + } + }, [currentFen]); + + return ( +
+
+
+

Moves

+

Auto-scrolling live notation

+
+
+ {sideToMove} to move +
+
+ +
+
+ {groupedMoves.map((pair) => { + const whiteIndex = (pair.moveNumber - 1) * 2; + const blackIndex = whiteIndex + 1; + const latestIndex = moves.length - 1; + + return ( + <> +
+ {pair.moveNumber}. +
+
+ {pair.white?.san ?? "—"} +
+
+ {pair.black?.san ?? "—"} +
+ + ); + })} +
+
+
+ +
+ {moves.length} half-moves tracked +
+
+ ); +} diff --git a/frontend/constants/mockLiveGames.ts b/frontend/constants/mockLiveGames.ts new file mode 100644 index 0000000..653be74 --- /dev/null +++ b/frontend/constants/mockLiveGames.ts @@ -0,0 +1,168 @@ +import type { SpectatorGameState, SpectatorMove } from "@/hook/useSpectatorSocket"; + +export interface LiveGameSummary { + gameId: string; + white: { address: string; elo: number }; + black: { address: string; elo: number }; + moveCount: number; + spectatorCount: number; + timeControl: string; + status: "playing" | "ending_soon"; +} + +export type LiveGameMode = "rated" | "casual"; + +export type MockLiveGameSummary = LiveGameSummary & { + mode: LiveGameMode; +}; + +export const MOCK_LIVE_GAMES: MockLiveGameSummary[] = [ + { + gameId: "mock-live-1", + white: { address: "GABC1STELLARXY01", elo: 1350 }, + black: { address: "GDEF2TACTICSUV12", elo: 1288 }, + moveCount: 18, + spectatorCount: 12, + timeControl: "10+0", + status: "playing", + mode: "rated", + }, + { + gameId: "mock-live-2", + white: { address: "GHIJ3ENDGAMEQR23", elo: 1492 }, + black: { address: "GKLM4OPENINGSMN34", elo: 1535 }, + moveCount: 41, + spectatorCount: 27, + timeControl: "5+3", + status: "ending_soon", + mode: "rated", + }, + { + gameId: "mock-live-3", + white: { address: "GNOP5RAPIDPLAY45", elo: 1180 }, + black: { address: "GQRS6SWISSPAIR56", elo: 1214 }, + moveCount: 12, + spectatorCount: 8, + timeControl: "15+10", + status: "playing", + mode: "casual", + }, + { + gameId: "mock-live-4", + white: { address: "GTUV7BLITZKING67", elo: 1722 }, + black: { address: "GWXY8TACTICIAN78", elo: 1684 }, + moveCount: 56, + spectatorCount: 41, + timeControl: "3+2", + status: "ending_soon", + mode: "rated", + }, + { + gameId: "mock-live-5", + white: { address: "GZA11ROOKLIFT89", elo: 1048 }, + black: { address: "GBB22PAWNSTRM90", elo: 1097 }, + moveCount: 9, + spectatorCount: 5, + timeControl: "10+5", + status: "playing", + mode: "casual", + }, + { + gameId: "mock-live-6", + white: { address: "GCC33QUEENSID12", elo: 1628 }, + black: { address: "GDD44FILESOPN34", elo: 1587 }, + moveCount: 29, + spectatorCount: 19, + timeControl: "5+0", + status: "playing", + mode: "rated", + }, + { + gameId: "mock-live-7", + white: { address: "GEE55MINORPCS56", elo: 1261 }, + black: { address: "GFF66LONGCAST78", elo: 1318 }, + moveCount: 22, + spectatorCount: 11, + timeControl: "20+0", + status: "playing", + mode: "casual", + }, + { + gameId: "mock-live-8", + white: { address: "GGG77TIMEPRS90", elo: 1774 }, + black: { address: "GHH88MIDGAME12", elo: 1812 }, + moveCount: 48, + spectatorCount: 36, + timeControl: "1+1", + status: "ending_soon", + mode: "rated", + }, +]; + +export const MOCK_SPECTATOR_GAME_STATE: SpectatorGameState = { + fen: "rnbqkbnr/ppp2ppp/4p3/3pP3/8/5N2/PPPP1PPP/RNBQKB1R w KQkq d6 0 3", + moves: [ + { from: "e2", to: "e4", san: "e4", color: "w" }, + { from: "d7", to: "d5", san: "d5", color: "b" }, + { from: "e4", to: "e5", san: "e5", color: "w" }, + { from: "e7", to: "e6", san: "e6", color: "b" }, + { from: "g1", to: "f3", san: "Nf3", color: "w" }, + ], + whiteTime: 540, + blackTime: 580, + status: "playing", + spectatorCount: 12, + white: { address: "GABC...XYZ1", elo: 1350 }, + black: { address: "GDEF...UVW2", elo: 1280 }, +}; + +const ALT_MOCK_MOVE_BANK: SpectatorMove[] = [ + { from: "d2", to: "d4", san: "d4", color: "w" }, + { from: "g8", to: "f6", san: "Nf6", color: "b" }, + { from: "c2", to: "c4", san: "c4", color: "w" }, + { from: "e7", to: "e6", san: "e6", color: "b" }, + { from: "g1", to: "f3", san: "Nf3", color: "w" }, + { from: "d7", to: "d5", san: "d5", color: "b" }, + { from: "b1", to: "c3", san: "Nc3", color: "w" }, + { from: "f8", to: "e7", san: "Be7", color: "b" }, + { from: "c1", to: "g5", san: "Bg5", color: "w" }, + { from: "h7", to: "h6", san: "h6", color: "b" }, + { from: "g5", to: "h4", san: "Bh4", color: "w" }, + { from: "b7", to: "b6", san: "b6", color: "b" }, +]; + +export function getMockLiveGameById(gameId: string) { + return MOCK_LIVE_GAMES.find((game) => game.gameId === gameId) ?? null; +} + +export function createMockSpectatorGameState(gameId: string): SpectatorGameState { + const summary = getMockLiveGameById(gameId); + if (!summary) { + return MOCK_SPECTATOR_GAME_STATE; + } + + const moveSlice = Math.max(1, Math.min(summary.moveCount, ALT_MOCK_MOVE_BANK.length)); + const moves = ALT_MOCK_MOVE_BANK.slice(0, moveSlice); + const baseState = moveSlice % 2 === 0 + ? { + fen: "rnbqk2r/p1p1bpp1/1p2pn1p/3p3b/2PP4/2N2N2/PP2PPPP/R2QKB1R w KQkq - 2 7", + whiteTime: 214, + blackTime: 239, + } + : { + fen: MOCK_SPECTATOR_GAME_STATE.fen, + whiteTime: 540, + blackTime: 580, + }; + + return { + ...MOCK_SPECTATOR_GAME_STATE, + ...baseState, + moves, + spectatorCount: summary.spectatorCount, + white: summary.white, + black: summary.black, + status: summary.status === "ending_soon" && summary.moveCount > 40 ? "resigned" : "playing", + result: summary.status === "ending_soon" && summary.moveCount > 40 ? "0-1" : undefined, + }; +} diff --git a/frontend/hook/useSpectatorSocket.ts b/frontend/hook/useSpectatorSocket.ts new file mode 100644 index 0000000..50778b9 --- /dev/null +++ b/frontend/hook/useSpectatorSocket.ts @@ -0,0 +1,401 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Chess } from "chess.js"; +import { + createMockSpectatorGameState, + getMockLiveGameById, +} from "@/constants/mockLiveGames"; + +export type SpectatorStatus = + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +export interface SpectatorMove { + from: string; + to: string; + san: string; + color: "w" | "b"; + promotion?: string; +} + +export interface SpectatorGameState { + fen: string; + moves: SpectatorMove[]; + whiteTime: number; + blackTime: number; + status: "playing" | "checkmate" | "stalemate" | "draw" | "resigned"; + spectatorCount: number; + white: { address: string; elo: number }; + black: { address: string; elo: number }; + result?: "1-0" | "0-1" | "1/2-1/2"; +} + +interface UseSpectatorSocketReturn { + status: SpectatorStatus; + gameState: SpectatorGameState | null; + disconnect: () => void; + reconnect: () => void; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000"; +const WS_BASE = API_BASE.replace(/^http/, "ws"); +const MAX_RECONNECT_ATTEMPTS = 10; +const INITIAL_RECONNECT_DELAY = 1000; +const MAX_RECONNECT_DELAY = 30000; +const RECONNECT_TIMEOUT = 3000; + +function normalizeGameOverReason(reason: string): SpectatorGameState["status"] { + const normalizedReason = reason.toLowerCase(); + + if (normalizedReason.includes("mate")) { + return "checkmate"; + } + + if (normalizedReason.includes("stale")) { + return "stalemate"; + } + + if (normalizedReason.includes("draw")) { + return "draw"; + } + + if (normalizedReason.includes("resign")) { + return "resigned"; + } + + return "draw"; +} + +export function useSpectatorSocket(gameId: string | null): UseSpectatorSocketReturn { + const [status, setStatus] = useState("idle"); + const [gameState, setGameState] = useState(null); + + const wsRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const reconnectTimeoutRef = useRef(null); + const reconnectTimerRef = useRef(null); + const isManualDisconnectRef = useRef(false); + const isOnlineRef = useRef(typeof navigator !== "undefined" ? navigator.onLine : true); + const chessRef = useRef(new Chess()); + const usingMockDataRef = useRef(false); + + const clearReconnectTimeout = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }, []); + + const calculateReconnectDelay = useCallback((attempt: number): number => { + const baseDelay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt); + const jitter = Math.random() * 0.3 * baseDelay; + return Math.min(baseDelay + jitter, MAX_RECONNECT_DELAY); + }, []); + + const bootstrapMockState = useCallback((nextGameId: string) => { + usingMockDataRef.current = true; + chessRef.current = new Chess(); + + const mockState = createMockSpectatorGameState(nextGameId); + try { + chessRef.current.load(mockState.fen); + } catch { + chessRef.current.reset(); + } + + setGameState(mockState); + setStatus("connected"); + }, []); + + const createWebSocket = useCallback( + (attemptReconnect = false): WebSocket | null => { + if (!gameId) { + return null; + } + + if (getMockLiveGameById(gameId)) { + bootstrapMockState(gameId); + return null; + } + + try { + const ws = new WebSocket(`${WS_BASE}/v1/games/${gameId}/spectate`); + wsRef.current = ws; + usingMockDataRef.current = false; + setStatus(attemptReconnect ? "reconnecting" : "connecting"); + + ws.onopen = () => { + setStatus("connected"); + reconnectAttemptsRef.current = 0; + ws.send(JSON.stringify({ type: "sync", gameId })); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as + | (SpectatorGameState & { type: "sync" }) + | ({ type: "move"; fen?: string } & SpectatorMove) + | { type: "clock"; whiteTime: number; blackTime: number } + | { type: "spectator_count"; count: number } + | { type: "game_over"; result: "1-0" | "0-1" | "1/2-1/2"; reason: string }; + + switch (data.type) { + case "sync": { + chessRef.current = new Chess(); + try { + chessRef.current.load(data.fen); + } catch { + chessRef.current.reset(); + for (const move of data.moves) { + try { + chessRef.current.move({ + from: move.from, + to: move.to, + promotion: move.promotion ?? "q", + }); + } catch { + break; + } + } + } + setGameState({ + fen: data.fen, + moves: data.moves, + whiteTime: data.whiteTime, + blackTime: data.blackTime, + status: data.status, + spectatorCount: data.spectatorCount, + white: data.white, + black: data.black, + result: data.result, + }); + break; + } + case "move": { + setGameState((previousState) => { + if (!previousState) { + return previousState; + } + + let nextFen = data.fen ?? previousState.fen; + + try { + chessRef.current.move({ + from: data.from, + to: data.to, + promotion: data.promotion ?? "q", + }); + if (!data.fen) { + nextFen = chessRef.current.fen(); + } + } catch { + nextFen = previousState.fen; + } + + return { + ...previousState, + fen: nextFen, + moves: [ + ...previousState.moves, + { + from: data.from, + to: data.to, + san: data.san, + color: data.color, + promotion: data.promotion, + }, + ], + }; + }); + break; + } + case "clock": { + setGameState((previousState) => + previousState + ? { + ...previousState, + whiteTime: data.whiteTime, + blackTime: data.blackTime, + } + : previousState, + ); + break; + } + case "spectator_count": { + setGameState((previousState) => + previousState + ? { + ...previousState, + spectatorCount: data.count, + } + : previousState, + ); + break; + } + case "game_over": { + setGameState((previousState) => + previousState + ? { + ...previousState, + status: normalizeGameOverReason(data.reason), + result: data.result, + } + : previousState, + ); + break; + } + } + } catch { + setStatus("error"); + } + }; + + ws.onerror = () => { + setStatus("error"); + }; + + ws.onclose = () => { + wsRef.current = null; + + if (isManualDisconnectRef.current) { + isManualDisconnectRef.current = false; + setStatus("idle"); + return; + } + + setStatus("disconnected"); + + if (!isOnlineRef.current || usingMockDataRef.current) { + return; + } + + if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { + setStatus("error"); + return; + } + + const delay = calculateReconnectDelay(reconnectAttemptsRef.current); + reconnectTimeoutRef.current = setTimeout(() => { + reconnectAttemptsRef.current += 1; + createWebSocket(true); + }, delay); + + reconnectTimerRef.current = setTimeout(() => { + if (status === "reconnecting") { + setStatus("reconnecting"); + } + }, RECONNECT_TIMEOUT); + }; + + return ws; + } catch { + setStatus("error"); + return null; + } + }, + [bootstrapMockState, calculateReconnectDelay, gameId, status], + ); + + const disconnect = useCallback(() => { + isManualDisconnectRef.current = true; + clearReconnectTimeout(); + reconnectAttemptsRef.current = 0; + usingMockDataRef.current = false; + + if (wsRef.current) { + wsRef.current.close(1000, "Manual disconnect"); + wsRef.current = null; + } else { + setStatus(gameId ? "idle" : "idle"); + } + }, [clearReconnectTimeout, gameId]); + + const reconnect = useCallback(() => { + if (!gameId) { + setStatus("idle"); + return; + } + + clearReconnectTimeout(); + reconnectAttemptsRef.current = 0; + isManualDisconnectRef.current = false; + usingMockDataRef.current = false; + + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + setGameState(null); + createWebSocket(true); + }, [clearReconnectTimeout, createWebSocket, gameId]); + + useEffect(() => { + const handleOnline = () => { + isOnlineRef.current = true; + if (status === "disconnected" && gameId && !usingMockDataRef.current) { + reconnectAttemptsRef.current = 0; + createWebSocket(true); + } + }; + + const handleOffline = () => { + isOnlineRef.current = false; + clearReconnectTimeout(); + }; + + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [clearReconnectTimeout, createWebSocket, gameId, status]); + + useEffect(() => { + if (!gameId) { + clearReconnectTimeout(); + setStatus("idle"); + setGameState(null); + return; + } + + const ws = createWebSocket(false); + + return () => { + isManualDisconnectRef.current = true; + clearReconnectTimeout(); + usingMockDataRef.current = false; + if (ws) { + ws.close(); + } + }; + }, [clearReconnectTimeout, createWebSocket, gameId]); + + useEffect(() => { + return () => { + clearReconnectTimeout(); + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, [clearReconnectTimeout]); + + return { + status, + gameState, + disconnect, + reconnect, + }; +} diff --git a/frontend/lib/spectatorUtils.ts b/frontend/lib/spectatorUtils.ts new file mode 100644 index 0000000..a8aa391 --- /dev/null +++ b/frontend/lib/spectatorUtils.ts @@ -0,0 +1,90 @@ +import { Chess } from "chess.js"; +import type { SpectatorMove } from "@/hook/useSpectatorSocket"; + +const WHITE_CAPTURED_SYMBOLS: Record = { + p: "♟", + n: "♞", + b: "♝", + r: "♜", + q: "♛", + k: "♚", +}; + +const BLACK_CAPTURED_SYMBOLS: Record = { + p: "♙", + n: "♘", + b: "♗", + r: "♖", + q: "♕", + k: "♔", +}; + +export function formatClock(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(safeSeconds / 60); + const remainingSeconds = safeSeconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; +} + +export function truncateAddress(address: string): string { + if (address.length <= 11) { + return address; + } + + return `${address.slice(0, 6)}...${address.slice(-5)}`; +} + +export function getCapturedFromMoves(moves: SpectatorMove[]): { + white: string[]; + black: string[]; +} { + const chess = new Chess(); + const captured = { + white: [] as string[], + black: [] as string[], + }; + + for (const move of moves) { + try { + const verboseMove = chess.move({ + from: move.from, + to: move.to, + promotion: move.promotion ?? "q", + }); + + if (!verboseMove?.captured) { + continue; + } + + if (move.color === "w") { + captured.white.push(verboseMove.captured); + } else { + captured.black.push(verboseMove.captured); + } + } catch { + continue; + } + } + + return captured; +} + +export function getGamePhase(moveCount: number): "opening" | "middlegame" | "endgame" { + if (moveCount <= 10) { + return "opening"; + } + + if (moveCount <= 30) { + return "middlegame"; + } + + return "endgame"; +} + +export function getCapturedPieceSymbols( + pieces: string[], + capturedBy: "white" | "black", +): string[] { + const symbolMap = capturedBy === "white" ? WHITE_CAPTURED_SYMBOLS : BLACK_CAPTURED_SYMBOLS; + return pieces.map((piece) => symbolMap[piece] ?? piece.toUpperCase()); +}