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
22 changes: 21 additions & 1 deletion src/components/common/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useAccount, useConnect, useDisconnect } from 'wagmi';
import { Copy, Check } from 'lucide-react';
import {
Expand All @@ -21,10 +21,12 @@ import { useCopySuccessAnnouncement } from '@/hooks/useCopySuccessAnnouncement';
import CopySuccessAnnouncement from '@/components/common/CopySuccessAnnouncement';
import showToast from '@/utils/toast.util';
import { copyTextToClipboard } from '@/utils/clipboard.utils';
import { logWalletDisconnectSession } from '@/lib/walletSessionLog';

function ConnectWalletButton() {
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
const [copied, setCopied] = useState(false);
const connectedAtRef = useRef<number | null>(null);
const { address, isConnected } = useAccount();
const { connect, connectors, error, isPending } = useConnect();
const { disconnect } = useDisconnect();
Expand All @@ -51,6 +53,17 @@ function ConnectWalletButton() {
}
};

useEffect(() => {
if (isConnected && address && connectedAtRef.current == null) {
connectedAtRef.current = Date.now();
return;
}

if (!isConnected) {
connectedAtRef.current = null;
}
}, [address, isConnected]);

if (isConnected && address) {
return (
<>
Expand Down Expand Up @@ -84,7 +97,14 @@ function ConnectWalletButton() {
type="button"
variant="destructive"
onClick={() => {
if (connectedAtRef.current != null) {
logWalletDisconnectSession(
address,
connectedAtRef.current
);
}
disconnect();
connectedAtRef.current = null;
setShowDisconnectDialog(false);
}}
>
Expand Down
20 changes: 15 additions & 5 deletions src/components/common/CreatorPageErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Link } from 'react-router';
import { AlertCircle, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ApiError } from '@/services/api.service';

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

/**
Expand All @@ -19,10 +21,11 @@ interface State {
class CreatorPageErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};

public static getDerivedStateFromError(): State {
return { hasError: true };
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
Expand All @@ -33,6 +36,10 @@ class CreatorPageErrorBoundary extends Component<Props, State> {

public render() {
if (this.state.hasError) {
const isNotFound =
this.state.error instanceof ApiError &&
this.state.error.status === 404;

return (
<main
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-[#06111f] px-6 py-16 text-center text-white"
Expand All @@ -42,11 +49,14 @@ class CreatorPageErrorBoundary extends Component<Props, State> {
<div className="flex flex-col items-center gap-3">
<AlertCircle className="size-10 text-amber-400" aria-hidden="true" />
<h1 className="font-grotesque text-3xl font-black tracking-tight sm:text-4xl">
This creator page could not load
{isNotFound
? 'Creator not found'
: 'This creator page could not load'}
</h1>
<p className="max-w-md font-jakarta text-base leading-7 text-white/70">
Something went wrong while loading this creator. The rest of the
marketplace is still available.
{isNotFound
? "We couldn't find a creator with that ID. Return to the creator list to keep browsing."
: 'Something went wrong while loading this creator. The rest of the marketplace is still available.'}
</p>
</div>
<Button
Expand Down
10 changes: 9 additions & 1 deletion src/components/common/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ const TransactionHistory: React.FC<TransactionHistoryProps> = ({
localStorage.setItem(COMPACT_VIEW_KEY, String(isCompact));
}, [isCompact]);

const sortedTransactions = [...transactions].sort((left, right) => {
if (left.timestamp !== right.timestamp) {
return right.timestamp - left.timestamp;
}

return right.id.localeCompare(left.id);
});

const toggleCompact = () => {
setIsCompact(!isCompact);
};
Expand Down Expand Up @@ -157,7 +165,7 @@ const TransactionHistory: React.FC<TransactionHistoryProps> = ({
</div>

<div className="space-y-2">
{transactions.map(tx => {
{sortedTransactions.map(tx => {
const displayHandle = formatCreatorHandle(tx.creatorHandle);
const isExpanded = expandedRows.has(tx.id) || !isCompact;
return (
Expand Down
47 changes: 43 additions & 4 deletions src/components/common/__tests__/ConnectWalletButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockUseConnect = vi.mocked(useConnect);
const mockUseDisconnect = vi.mocked(useDisconnect);

const FULL_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';
const TRUNCATED_ADDRESS_PATTERN = /0x12.*5678/i;

function setupConnectedWalletMocks(disconnect = vi.fn()) {
mockUseAccount.mockReturnValue({
Expand Down Expand Up @@ -44,7 +45,9 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => {
it('opens a confirmation dialog before disconnecting', () => {
const { disconnect } = renderConnectedWallet();

fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
fireEvent.click(
screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
);

expect(
screen.getByRole('dialog', { name: /disconnect wallet/i })
Expand All @@ -55,16 +58,50 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => {
it('disconnects when the confirmation action is clicked', () => {
const { disconnect } = renderConnectedWallet();

fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
fireEvent.click(
screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
);
fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));

expect(disconnect).toHaveBeenCalledTimes(1);
});

it('emits a structured disconnect log with session duration outside test env', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-27T10:00:00.000Z'));
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const { disconnect } = renderConnectedWallet();

act(() => {
vi.advanceTimersByTime(4_500);
});

fireEvent.click(
screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
);
fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));

expect(disconnect).toHaveBeenCalledTimes(1);
expect(debugSpy).toHaveBeenCalledWith('[wallet-disconnect]', {
truncated_address: '0x12...5678',
session_duration_ms: 4_500,
disconnected_at: '2026-07-27T10:00:04.500Z',
});
expect(JSON.stringify(debugSpy.mock.calls[0][1])).not.toContain(FULL_ADDRESS);

debugSpy.mockRestore();
process.env.NODE_ENV = originalEnv;
vi.useRealTimers();
});

it('cancels without disconnecting', async () => {
const { disconnect } = renderConnectedWallet();

fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
fireEvent.click(
screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));

await waitFor(() => {
Expand All @@ -76,7 +113,9 @@ describe('ConnectWalletButton wallet disconnect confirmation', () => {
it('dismisses with Escape without disconnecting', async () => {
const { disconnect } = renderConnectedWallet();

fireEvent.click(screen.getByRole('button', { name: /0x1234/i }));
fireEvent.click(
screen.getByRole('button', { name: TRUNCATED_ADDRESS_PATTERN })
);
fireEvent.keyDown(document, { key: 'Escape' });

await waitFor(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import TransactionHistory, {
type Transaction,
} from '@/components/common/TransactionHistory';

const trades: Transaction[] = [
{
id: 'ledger-1000',
type: 'buy',
creatorId: 'creator-c',
creatorHandle: 'nova',
amount: 1,
price: 8,
timestamp: 1_000,
txHash: '0xccc',
status: 'completed',
},
{
id: 'ledger-3000',
type: 'buy',
creatorId: 'creator-a',
creatorHandle: 'atlas',
amount: 5,
price: 12,
timestamp: 3_000,
txHash: '0xaaa',
status: 'completed',
},
{
id: 'ledger-2000',
type: 'sell',
creatorId: 'creator-b',
creatorHandle: 'beacon',
amount: 2,
price: 10,
timestamp: 2_000,
txHash: '0xbbb',
status: 'completed',
},
];

beforeEach(() => {
vi.stubEnv('NODE_ENV', 'test');
localStorage.clear();
});

describe('TransactionHistory – wallet activity order and type labels (integration)', () => {
it('renders trades in descending chronological order with correct buy and sell labels', () => {
render(<TransactionHistory transactions={trades} />);

const rows = screen
.getAllByTestId(/activity-item-/)
.map(row => row.textContent ?? '');

expect(rows).toHaveLength(3);
expect(rows[0]).toContain('@atlas');
expect(rows[1]).toContain('@beacon');
expect(rows[2]).toContain('@nova');

const buyRows = screen.getAllByTestId('activity-item-buy');
const sellRows = screen.getAllByTestId('activity-item-sell');

expect(within(buyRows[0]).getByText('Buy')).toBeInTheDocument();
expect(within(buyRows[1]).getByText('Buy')).toBeInTheDocument();
expect(within(sellRows[0]).getByText('Sell')).toBeInTheDocument();
expect(buyRows).toHaveLength(2);
expect(sellRows).toHaveLength(1);
expect(rows[0]).toContain('5 keys');
expect(rows[1]).toContain('2 keys');
expect(rows[2]).toContain('1 keys');
});
});
34 changes: 34 additions & 0 deletions src/lib/__tests__/walletSessionLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import {
createWalletDisconnectLog,
logWalletDisconnectSession,
} from '@/lib/walletSessionLog';

describe('walletSessionLog', () => {
it('builds a structured disconnect log with a truncated address and session duration', () => {
const address = '0x1234567890abcdef1234567890abcdef12345678';
const connectedAt = 1_000;
const disconnectedAt = 4_250;

expect(
createWalletDisconnectLog(address, connectedAt, disconnectedAt)
).toEqual({
truncated_address: '0x12...5678',
session_duration_ms: 3_250,
disconnected_at: '1970-01-01T00:00:04.250Z',
});
});

it('does not emit a disconnect log in the test environment', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});

logWalletDisconnectSession(
'0x1234567890abcdef1234567890abcdef12345678',
1_000,
2_000
);

expect(debugSpy).not.toHaveBeenCalled();
debugSpy.mockRestore();
});
});
34 changes: 34 additions & 0 deletions src/lib/walletSessionLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { shortenAddress } from '@/lib/web3/format';

export interface WalletDisconnectLogEntry {
truncated_address: string;
session_duration_ms: number;
disconnected_at: string;
}

export function createWalletDisconnectLog(
address: string,
connectedAt: number,
disconnectedAt: number = Date.now()
): WalletDisconnectLogEntry {
return {
truncated_address: shortenAddress(address),
session_duration_ms: Math.max(0, disconnectedAt - connectedAt),
disconnected_at: new Date(disconnectedAt).toISOString(),
};
}

export function logWalletDisconnectSession(
address: string,
connectedAt: number,
disconnectedAt: number = Date.now()
) {
if (process.env.NODE_ENV === 'test') {
return;
}

console.debug(
'[wallet-disconnect]',
createWalletDisconnectLog(address, connectedAt, disconnectedAt)
);
}
9 changes: 7 additions & 2 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid';
import { CreatorProfileHeaderSkeleton } from '@/components/common/CreatorSkeleton';
import { bpsToPercent } from '@/utils/numberFormat.utils';
import CreatorPageErrorBoundary from '@/components/common/CreatorPageErrorBoundary';
import { ApiError } from '@/services/api.service';

function CreatorDetailPageContent() {
const { id } = useParams<{ id: string }>();
Expand All @@ -21,8 +22,12 @@ function CreatorDetailPageContent() {
);
}

if (error || !creator) {
throw new Error('Creator not found');
if (error) {
throw error;
}

if (!creator) {
throw new ApiError('Creator not found', 404);
}

const feeItems = [
Expand Down
Loading
Loading