Skip to content
Draft
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
23 changes: 22 additions & 1 deletion apps/scan/src/app/(home)/(overview)/_components/heading.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import Link from 'next/link';

import { Plus } from 'lucide-react';
Expand All @@ -8,12 +10,31 @@ import { Button } from '@/components/ui/button';
import { Logo } from '@/components/logo';

import { SearchButton } from './search-button';
import { useState } from 'react';

export const HomeHeading = () => {
const [clickCount, setClickCount] = useState(0);

const handleLogoClick = () => {
const newCount = clickCount + 1;
setClickCount(newCount);

if (newCount === 5) {
// Dispatch custom event to open modal
window.dispatchEvent(new CustomEvent('open-verified-filter-modal'));
setClickCount(0); // Reset counter
}

// Reset counter after 1 second of no clicks
setTimeout(() => {
setClickCount(0);
}, 1000);
};
Comment on lines +18 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout management in the logo click handler doesn't properly reset the counter. Each click creates a new timeout without clearing previous ones, which can cause the click counter to reset unexpectedly if there's a delay between clicks, making it difficult or impossible to trigger the 5-click easter egg.

View Details
📝 Patch Details
diff --git a/apps/scan/src/app/(home)/(overview)/_components/heading.tsx b/apps/scan/src/app/(home)/(overview)/_components/heading.tsx
index c8f2a3a9..49bbe440 100644
--- a/apps/scan/src/app/(home)/(overview)/_components/heading.tsx
+++ b/apps/scan/src/app/(home)/(overview)/_components/heading.tsx
@@ -10,12 +10,18 @@ import { Button } from '@/components/ui/button';
 import { Logo } from '@/components/logo';
 
 import { SearchButton } from './search-button';
-import { useState } from 'react';
+import { useState, useRef } from 'react';
 
 export const HomeHeading = () => {
   const [clickCount, setClickCount] = useState(0);
+  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
 
   const handleLogoClick = () => {
+    // Clear previous timeout if it exists
+    if (timeoutRef.current) {
+      clearTimeout(timeoutRef.current);
+    }
+
     const newCount = clickCount + 1;
     setClickCount(newCount);
 
@@ -23,11 +29,14 @@ export const HomeHeading = () => {
       // Dispatch custom event to open modal
       window.dispatchEvent(new CustomEvent('open-verified-filter-modal'));
       setClickCount(0); // Reset counter
+      timeoutRef.current = null;
+      return;
     }
 
     // Reset counter after 1 second of no clicks
-    setTimeout(() => {
+    timeoutRef.current = setTimeout(() => {
       setClickCount(0);
+      timeoutRef.current = null;
     }, 1000);
   };
 

Analysis

Multiple Timeouts Prevent 5-Click Easter Egg in Logo Click Handler

What fails: The handleLogoClick() function in apps/scan/src/app/(home)/(overview)/_components/heading.tsx creates a new 1-second timeout on every click without clearing previous timeouts. When clicks are spaced apart, multiple timeouts accumulate and fire independently, resetting the click counter unexpectedly and preventing the user from reaching 5 clicks to trigger the easter egg event.

How to reproduce:

  1. Open the application in the browser
  2. Click the logo/heading area slowly with ~400ms delay between clicks
  3. After click 2, wait for the first timeout to expire (~1 second after click 1)
  4. The click counter resets to 0 despite click 2 having occurred
  5. Attempt to reach 5 clicks - this becomes impossible due to unexpected resets

What happens: Multiple setTimeout() calls accumulate in the event queue. Each creates its own independent timeout that will fire and call setClickCount(0). When click 1 occurs at t=0 and click 2 at t=400ms:

  • Timeout A fires at t=1000ms → counter resets to 0
  • Timeout B fires at t=1400ms → counter resets to 0 again
  • Click 3 at t=1100ms sees counter already reset to 0, not the expected count of 2

Expected: Only one timeout should be active at any time. When a new click occurs, the previous timeout should be cleared before creating a new one. This ensures the counter only resets after 1 second of NO clicks, allowing users to reliably reach 5 clicks to trigger the easter egg.

Fix implemented: Use a useRef to maintain a reference to the active timeout ID, clear any previous timeout before setting a new one, and set the ref to null after the timeout fires or when the 5-click event triggers. This ensures only one timer is ever active simultaneously.


return (
<HeadingContainer className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" onClick={handleLogoClick}>
<Logo className="size-8" />
<h1 className="text-2xl md:text-4xl font-bold font-mono">x402scan</h1>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import { columns } from './columns';
import { useSellersSorting } from '../../../../../_contexts/sorting/sellers/hook';
import { useTimeRangeContext } from '@/app/_contexts/time-range/hook';
import { useChain } from '@/app/_contexts/chain/hook';
import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook';
import { useState } from 'react';

export const AllSellersTable = () => {
const { sorting } = useSellersSorting();
const { timeframe } = useTimeRangeContext();
const { chain } = useChain();
const { verifiedOnly } = useVerifiedFilter();

const [page, setPage] = useState(0);
const pageSize = 10;
Expand All @@ -25,6 +27,7 @@ export const AllSellersTable = () => {
page,
},
timeframe,
verifiedOnly,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,12 @@ export const columns: ExtendedColumnDef<ColumnType>[] = [
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Origins
origins={row.original.origins}
addresses={row.original.recipients}
disableCopy
/>
</div>
<Origins
origins={row.original.origins}
addresses={row.original.recipients}
disableCopy
hasVerifiedAccept={row.original.hasVerifiedAccept}
/>
);
},
size: 225,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { Section } from '@/app/_components/layout/page-utils';
import { RangeSelector } from '@/app/_contexts/time-range/component';

export const TopServersContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<Section
title="Top Servers"
description="Top addresses that have received x402 transfers and are listed in the Bazaar"
actions={
<div className="flex items-center gap-2">
<RangeSelector />
</div>
}
>
{children}
</Section>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@ import { Suspense } from 'react';

import { ErrorBoundary } from 'react-error-boundary';

import { Section } from '@/app/_components/layout/page-utils';

import { KnownSellersTable, LoadingKnownSellersTable } from './table';
import { TopServersContainer } from './container';

import { api, HydrateClient } from '@/trpc/server';

import { defaultSellersSorting } from '@/app/_contexts/sorting/sellers/default';
import { SellersSortingProvider } from '@/app/_contexts/sorting/sellers/provider';

import { TimeRangeProvider } from '@/app/_contexts/time-range/provider';
import { RangeSelector } from '@/app/_contexts/time-range/component';

import { ActivityTimeframe } from '@/types/timeframes';

Expand Down Expand Up @@ -60,19 +58,3 @@ export const LoadingTopServers = () => {
</TopServersContainer>
);
};

const TopServersContainer = ({ children }: { children: React.ReactNode }) => {
return (
<Section
title="Top Servers"
description="Top addresses that have received x402 transfers and are listed in the Bazaar"
actions={
<div className="flex items-center gap-2">
<RangeSelector />
</div>
}
>
{children}
</Section>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DataTable } from '@/components/ui/data-table';
import { useSellersSorting } from '@/app/_contexts/sorting/sellers/hook';
import { useTimeRangeContext } from '@/app/_contexts/time-range/hook';
import { useChain } from '@/app/_contexts/chain/hook';
import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook';

import { columns } from './columns';
import { api } from '@/trpc/client';
Expand All @@ -13,6 +14,7 @@ export const KnownSellersTable = () => {
const { sorting } = useSellersSorting();
const { timeframe } = useTimeRangeContext();
const { chain } = useChain();
const { verifiedOnly } = useVerifiedFilter();

const [topSellers] = api.public.sellers.bazaar.list.useSuspenseQuery({
chain,
Expand All @@ -21,6 +23,7 @@ export const KnownSellersTable = () => {
},
timeframe,
sorting,
verifiedOnly,
});

return <DataTable columns={columns} data={topSellers.items} pageSize={10} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { api } from '@/trpc/client';

import { useTimeRangeContext } from '@/app/_contexts/time-range/hook';
import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook';

import { LoadingOverallStatsCard, OverallStatsCard } from './card';

Expand All @@ -14,16 +15,19 @@ import { useChain } from '@/app/_contexts/chain/hook';
export const OverallCharts = () => {
const { timeframe } = useTimeRangeContext();
const { chain } = useChain();
const { verifiedOnly } = useVerifiedFilter();

const [overallStats] = api.public.stats.overall.useSuspenseQuery({
chain,
timeframe,
verifiedOnly,
});

const [bucketedStats] = api.public.stats.bucketed.useSuspenseQuery({
numBuckets: 48,
timeframe,
chain,
verifiedOnly,
});

const chartData: ChartData<{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client';

import { useEffect, useState } from 'react';
import { VerifiedFilterProvider } from '@/app/_contexts/verified-filter/provider';
import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { CheckCircle } from 'lucide-react';

export const VerifiedFilterWrapper = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<VerifiedFilterProvider>
<VerifiedFilterDialog />
{children}
</VerifiedFilterProvider>
);
};

// Reusable toggle switch component
const VerifiedToggleSwitch = () => {
const { verifiedOnly, setVerifiedOnly } = useVerifiedFilter();

const handleToggle = () => {
setVerifiedOnly(!verifiedOnly);
};

return (
<div className="flex items-center justify-between py-4">
<Label htmlFor="verified-toggle" className="text-sm font-medium">
Show only verified servers
</Label>
<button
id="verified-toggle"
type="button"
role="switch"
aria-checked={verifiedOnly}
onClick={handleToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${
verifiedOnly ? 'bg-green-500' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
verifiedOnly ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
);
};

// Dialog with keyboard shortcut (triple tap 'v') and logo click (5 times)
const VerifiedFilterDialog = () => {
const [showModal, setShowModal] = useState(false);

// Keyboard shortcut (triple tap 'v')
useEffect(() => {
let tapCount = 0;
let tapTimeout: NodeJS.Timeout;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'v' || e.key === 'V') {
tapCount++;

// Clear existing timeout
if (tapTimeout) {
clearTimeout(tapTimeout);
}

// Open modal on third tap
if (tapCount === 3) {
e.preventDefault();
setShowModal(true);
tapCount = 0;
} else {
// Reset counter after 500ms of no taps
tapTimeout = setTimeout(() => {
tapCount = 0;
}, 500);
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (tapTimeout) {
clearTimeout(tapTimeout);
}
};
}, []);

// Listen for custom event from logo clicks
useEffect(() => {
const handleOpenModal = () => {
setShowModal(true);
};

window.addEventListener('open-verified-filter-modal', handleOpenModal);
return () => {
window.removeEventListener('open-verified-filter-modal', handleOpenModal);
};
}, []);

return (
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CheckCircle className="size-4 text-green-500" />
Verified Servers Filter
</DialogTitle>
<DialogDescription>
Filter servers to only show those with verified accepts. Volume and
metrics are recalculated to only include transactions to verified
addresses.
</DialogDescription>
</DialogHeader>
<VerifiedToggleSwitch />
</DialogContent>
</Dialog>
);
};
3 changes: 3 additions & 0 deletions apps/scan/src/app/(home)/_components/transactions/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DataTable } from '@/components/ui/data-table';

import { useChain } from '@/app/_contexts/chain/hook';
import { useTransfersSorting } from '@/app/_contexts/sorting/transfers/hook';
import { useVerifiedFilter } from '@/app/_contexts/verified-filter/hook';

import { columns } from './columns';

Expand All @@ -20,6 +21,7 @@ interface Props {
export const Table: React.FC<Props> = ({ pageSize }) => {
const { sorting } = useTransfersSorting();
const { chain } = useChain();
const { verifiedOnly } = useVerifiedFilter();

const [page, setPage] = useState(0);

Expand All @@ -31,6 +33,7 @@ export const Table: React.FC<Props> = ({ pageSize }) => {
},
sorting,
timeframe: ActivityTimeframe.ThirtyDays,
verifiedOnly,
});

return (
Expand Down
Loading
Loading