Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions frontend/src/app/dashboard/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Skeleton } from "@/components/ui/Skeleton";
import { useReduceMotion } from "@/hooks/useReduceMotion";
import { useAllBotDetails } from "@/hooks/useBotDetails";
import { useAmtBalance } from "@/hooks/useAmtBalance";
import { useBots } from "@/hooks/useBots";
import { useProfile } from "@/hooks/useProfile";
import { useRegistered } from "@/hooks/useRegistered";
import { useWallet } from "@/hooks/useWallet";

export default function DashboardLoading() {
const { isConnected } = useWallet();
const {
isCheckingRegistration,
isRefetching: isRegRefetching,
} = useRegistered();

const {
isRefetching: isProfileRefetching,
} = useProfile();

const {
isBots,
isBotsRefetching,
isBotsError,
} = useBots();

const {
isRefetching: isAccrualRefetching,
isError: isAccrualError,
} = useAccrualState();

const {
isRefetching: isBotsDetailsRefetching,
isError: isBotsDetailsError,
} = useAllBotDetails();

const {
isPending: isAmtBalancePending,
} = useAmtBalance();

const reducedMotion = useReduceMotion();

return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="flex min-h-[400px] items-center justify-center rounded-2xl border border-liner bg-card p-12">
<Skeleton
className={reducedMotion ? "h-16 w-16 rounded-2xl bg-card-2" : "h-16 w-16 rounded-2xl bg-card-2 animate-pulse"}
/>
<Skeleton className={reducedMotion ? "mt-4 h-4 w-48" : "mt-4 h-4 w-48 animate-pulse"} />
</div>

{isConnected && !isCheckingRegistration && !isProfileRefetching ? (
<div className="grid gap-6 lg:grid-cols-2">
{/* Left column */}
<div className="flex flex-col gap-6">
<Skeleton
className="rounded-2xl border border-liner bg-card p-5"
reducedMotion
>
<Skeleton className="h-11 w-11 rounded-xl" reducedMotion />
<Skeleton className="flex flex-col gap-2 reducedMotion">
<Skeleton className="h-4 w-28" reducedMotion />
<Skeleton className="h-3 w-16" reducedMotion />
</S>
82 changes: 69 additions & 13 deletions frontend/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { useAllBotDetails } from "@/hooks/useBotDetails";
import { getPendingPoints } from "@/lib/contracts";
import { useState, useEffect } from "react";
import { Skeleton } from "@/components/ui/Skeleton";
import { PointsCounter } from "@/components/dashboard/PointsCounter";
import ClaimButton from "@/components/dashboard/ClaimButton";
import BotCard from "@/components/dashboard/BotCard";
Expand All @@ -21,6 +22,7 @@ import { ErrorState } from "@/components/ui/ErrorState";
import { Wallet, Loader2, Bot } from "lucide-react";
import clsx from "clsx";
import type { BotNFT } from "@/types";
import { useReduceMotion } from "@/hooks/useReduceMotion";

export default function DashboardPage() {
const { publicKey, isConnected, connect, isConnecting } = useWallet();
Expand Down Expand Up @@ -54,6 +56,7 @@ export default function DashboardPage() {
isError: isAccrualError,
error: accrualError,
refetch: refetchAccrual,
isRefetching: isAccrualRefetching,
} = useAccrualState();

const {
Expand All @@ -64,15 +67,26 @@ export default function DashboardPage() {
isRefetching: isBotsDetailsRefetching,
} = useAllBotDetails(botIds || []);

const { data: amtBalance } = useAmtBalance();
const { data: amtBalance, isPending: isAmtBalancePending } = useAmtBalance();
const claim = useClaim();

const [pendingPoints, setPendingPoints] = useState<bigint>(BigInt(0));

const reduceMotion = useReduceMotion();

const isAnyError = isRegError || isProfileError || isBotsError || isAccrualError || isBotsDetailsError;
const activeError = regError || profileError || botsError || accrualError || botsDetailsError;
const isRetrying = isRegRefetching || isProfileRefetching || isBotsRefetching || isBotsDetailsRefetching;

// Check if any query is loading
const isLoading =
isCheckingRegistration ||
isProfileRefetching ||
isBotsRefetching ||
isBotsDetailsRefetching ||
isAccrualRefetching ||
isAmtBalancePending;

const handleRetryAll = () => {
refetchReg();
refetchProfile();
Expand Down Expand Up @@ -148,18 +162,51 @@ export default function DashboardPage() {
);
}

// Loading state
if (isCheckingRegistration) {
// Loading state - show skeletons while queries are in flight
if (isLoading) {
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="flex min-h-[400px] items-center justify-center rounded-2xl border border-liner bg-card p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted" aria-hidden="true" />
<div className="grid gap-6 lg:grid-cols-2">
{/* Left column */}
<div className="flex flex-col gap-6">
<Skeleton className="rounded-2xl border border-liner bg-card p-5" reducedMotion>
<Skeleton className="h-11 w-11 rounded-xl" reducedMotion />
<Skeleton className="flex flex-col gap-2 reducedMotion">
<Skeleton className="h-4 w-28" reducedMotion />
<Skeleton className="h-3 w-16" reducedMotion />
</Skeleton>
</Skeleton>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>
<Skeleton className="h-7 w-48 reducedMotion" />
</Skeleton>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>
<Skeleton className="h-7 w-48 reducedMotion" />
</Skeleton>
</div>

{/* Right column */}
<div className="flex flex-col gap-6">
<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>

<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>
<Skeleton className="h-12 w-12 items-center justify-center rounded-xl bg-card-2 reducedMotion" />
<Skeleton className="mt-3 text-sm text-muted reducedMotion" />
</Skeleton>
</div>
</div>
</div>
);
}

// Error state for registration or initial query failures (#513)
// Error state for registration or initial query failures
if (isRegError) {
return (
<div className="mx-auto max-w-7xl px-6 py-8">
Expand Down Expand Up @@ -223,14 +270,14 @@ export default function DashboardPage() {
<div className="flex flex-col gap-6">
{/* Points Counter */}
<PointsCounter
points={Number(profile?.points || BigInt(0))}
rate={totalRate}
bots={bots || []}
amtBalance={amtBalance ?? BigInt(0)}
points={isLoading ? BigInt(0) : Number(profile?.points || BigInt(0))}
rate={isLoading ? 0 : totalRate}
bots={isLoading ? [] : bots || []}
amtBalance={isLoading ? BigInt(0) : amtBalance ?? BigInt(0)}
/>

{/* Claim Button */}
{pendingPoints > BigInt(0) && (
{pendingPoints > BigInt(0) && !isLoading && (
<ClaimButton
pendingPoints={pendingPoints}
onClaim={handleClaim}
Expand All @@ -246,7 +293,7 @@ export default function DashboardPage() {
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold text-text">Your Bots</h2>
<span className="text-sm text-muted">{bots?.length || 0} owned</span>
<span className="text-sm text-muted">{isLoading ? loading... : bots?.length || 0} owned</span>
</div>

{isBotsError || isBotsDetailsError ? (
Expand All @@ -262,6 +309,15 @@ export default function DashboardPage() {
compact
data-testid="bots-error-state"
/>
) : isLoading ? (
<Skeleton className="rounded-2xl border border-liner bg-card p-5 reducedMotion" reducedMotion>
<Skeleton className="h-11 w-11 rounded-xl" reducedMotion />
<Skeleton className="flex flex-col gap-2 reducedMotion">
<Skeleton className="h-4 w-28" reducedMotion />
<Skeleton className="h-3 w-16" reducedMotion />
</Skeleton>
<Skeleton className="h-6 w-16 rounded-full" reducedMotion />
</Skeleton>
) : bots && bots.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{bots.map((bot: BotNFT) => (
Expand All @@ -282,4 +338,4 @@ export default function DashboardPage() {
</div>
</div>
);
}
}
54 changes: 23 additions & 31 deletions frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,26 @@
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Menu, X, Wallet, LogOut, AlertTriangle, Loader2, Download } from "lucide-react";
import { Menu, X, Wallet, LogOut, AlertTriangle, Loader2, Download, Copy } from "lucide-react";
import clsx from "clsx";
import { useWallet } from "@/hooks/useWallet";
import { useFocusLock } from "@/hooks/useFocusLock";
import { truncateAddress, fullAddressTitle, fullAddressAriaLabel, useCopyToClipboard } from "@/lib/truncateAddress";

const navLinks = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Marketplace", href: "/marketplace" },
{ label: "Leaderboard", href: "/leaderboard" },
];

function truncateAddress(address: string): string {
if (address.length <= 10) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
]

export default function Header() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { publicKey, isConnected, networkMismatch, isConnecting, isNotInstalled, connect, disconnect } = useWallet();
const mobileMenuRef = useRef<HTMLDivElement>(null);
const [isAddressCopied, setIsAddressCopied] = useState(false);

// Close mobile menu on Escape
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") setMobileOpen(false);
},
[],
);

useEffect(() => {
if (mobileOpen) {
document.addEventListener("keydown", handleKeyDown);
// Prevent body scroll when mobile menu is open
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [mobileOpen, handleKeyDown]);
useFocusLock(mobileMenuRef, () => setMobileOpen(false));

// Close mobile menu on route change
useEffect(() => {
Expand Down Expand Up @@ -137,16 +118,26 @@ export default function Header() {
{isConnected && publicKey ? (
<div className="flex items-center gap-2 rounded-xl border border-liner bg-card-2 px-3 py-2">
<Wallet className="h-4 w-4 text-green" aria-hidden="true" />
<span className="text-sm font-medium text-text">
<span
className="text-sm font-medium text-text"
title={fullAddressTitle(publicKey)}
aria-label={fullAddressAriaLabel(publicKey)}
>
{truncateAddress(publicKey)}
</span>
<button
onClick={disconnect}
className="ml-1 rounded-lg p-1 text-muted hover:text-text transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
aria-label="Disconnect wallet"
onClick={() => {
useCopyToClipboard(publicKey).handleCopy();
setIsAddressCopied(true);
setTimeout(() => setIsAddressCopied(false), 2000);
}}
className="ml-2 rounded-lg p-1 text-xs text-gold hover:bg-gold/10 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
aria-label="Copy address"
title="Copy address"
>
<LogOut className="h-3.5 w-3.5" />
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
</button>
{isAddressCopied && <span className="ml-1 text-xs text-green">Copied!</span>}
</div>
) : (
<button
Expand Down Expand Up @@ -183,6 +174,7 @@ export default function Header() {

{mobileOpen && (
<div
ref={mobileMenuRef}
className="border-t border-liner md:hidden"
role="dialog"
aria-label="Mobile navigation"
Expand Down
22 changes: 13 additions & 9 deletions frontend/src/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { motion } from "framer-motion";
import clsx from "clsx";
import { formatPoints, type UserProfile } from "@/types";
import type { UserRank } from "@/lib/contracts";
import { truncateAddress, fullAddressTitle, fullAddressAriaLabel, useCopyToClipboard } from "@/lib/truncateAddress";

/**
* A leaderboard row: exactly the profile the registry returns, plus the
Expand Down Expand Up @@ -37,11 +38,6 @@ export interface LeaderboardTableProps {
currentUserRank?: UserRank | null;
}

function truncateAddress(address: string): string {
if (!address || address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

const RANK_ICON: Record<number, { icon: string; color: string; label: string }> = {
1: { icon: "🥇", color: "text-gold", label: "1st place" },
2: { icon: "🥈", color: "text-tier-silver", label: "2nd place" },
Expand Down Expand Up @@ -106,7 +102,12 @@ function LeaderboardCard({
<div className="mt-2 flex items-center justify-between gap-2">
<span className="font-semibold text-text">{user.points.toLocaleString()} pts</span>
{user.address && (
<span className="font-mono text-xs text-muted">{truncateAddress(user.address)}</span>
<span className="font-mono text-xs text-muted"
title={fullAddressTitle(user.address)}
aria-label={fullAddressAriaLabel(user.address)}
>
{truncateAddress(user.address)}
</span>
)}
</div>
</motion.div>
Expand Down Expand Up @@ -524,9 +525,12 @@ function LeaderboardTableComponent({ users, currentAddress }: LeaderboardTablePr
</td>

{/* Truncated address */}
<td className="px-2 py-3 font-mono text-xs text-muted sm:px-4">
{truncateAddress(user.address)}
</td>
<td className="px-2 py-3 font-mono text-xs text-muted sm:px-4"
title={fullAddressTitle(user.address)}
aria-label={fullAddressAriaLabel(user.address)}
>
{truncateAddress(user.address)}
</td>
</motion.tr>
);
})}
Expand Down
Loading