Skip to content
Open
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
74 changes: 59 additions & 15 deletions src/components/DutchAuctionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import type { DutchAuction } from '../types/dutchAuction';
import { computeEffectiveStatus } from '../types/dutchAuction';
import { COLOR, fmt } from '../utils/tokens';
import { PendingButton } from './PendingButton';

Expand Down Expand Up @@ -45,17 +46,47 @@ const formatTimeLeft = (endTime: string): string => {
export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onPurchase }) => {
const [currentPrice, setCurrentPrice] = useState(calculateCurrentPrice(auction));
const [timeLeft, setTimeLeft] = useState(formatTimeLeft(auction.endTime));
const [effectiveStatus, setEffectiveStatus] = useState(() =>
computeEffectiveStatus(auction),
);
const [isPurchasing, setIsPurchasing] = useState(false);
const purchaseAttemptRef = useRef(0);

useEffect(() => {
if (auction.status !== 'Active') return;
if (effectiveStatus !== 'Active') return;

const interval = setInterval(() => {
const now = Date.now();
setCurrentPrice(calculateCurrentPrice(auction));
setTimeLeft(formatTimeLeft(auction.endTime));

const status = computeEffectiveStatus(auction, now);
setEffectiveStatus(status);

if (status !== 'Active') {
clearInterval(interval);
}
}, 1000);

return () => clearInterval(interval);
}, [auction]);
}, [auction, effectiveStatus]);

const handlePurchase = useCallback(async () => {
if (effectiveStatus !== 'Active' || isPurchasing) return;

const attemptId = ++purchaseAttemptRef.current;
setIsPurchasing(true);

try {
await onPurchase?.(auction.id, currentPrice);
} finally {
if (purchaseAttemptRef.current === attemptId) {
setIsPurchasing(false);
}
}
}, [effectiveStatus, isPurchasing, onPurchase, auction.id, currentPrice]);

const isEnded = effectiveStatus === 'Completed' || effectiveStatus === 'Cancelled';

return (
<div className="card" style={{ padding: '1rem', marginBottom: '1rem' }}>
Expand Down Expand Up @@ -87,19 +118,19 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 500,
background: auction.status === 'Active'
? 'rgba(63,185,80,0.16)'
: auction.status === 'Completed'
background: effectiveStatus === 'Active'
? 'rgba(63,185,80,0.16)'
: effectiveStatus === 'Completed'
? 'rgba(88,166,255,0.16)'
: 'rgba(248,81,73,0.16)',
color: auction.status === 'Active'
color: effectiveStatus === 'Active'
? '#8ee99d'
: auction.status === 'Completed'
: effectiveStatus === 'Completed'
? '#58a6ff'
: '#ffb0aa',
}}
>
{auction.status}
{effectiveStatus}
</div>
</div>

Expand All @@ -113,7 +144,7 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
Current Price
</p>
<p style={{ margin: 0, color: COLOR.accent, fontSize: '1.25rem', fontWeight: 600 }}>
{auction.status === 'Active' ? fmt(currentPrice) : auction.finalPrice ? fmt(auction.finalPrice) : '-'}
{effectiveStatus === 'Active' ? fmt(currentPrice) : auction.finalPrice ? fmt(auction.finalPrice) : '-'}
</p>
</div>
<div>
Expand All @@ -124,7 +155,7 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
{fmt(auction.startPrice)} / {fmt(auction.floorPrice)}
</p>
</div>
{auction.status === 'Active' && (
{effectiveStatus === 'Active' && (
<div>
<p style={{ margin: 0, color: COLOR.muted, fontSize: '0.75rem' }}>
Time Left
Expand All @@ -134,7 +165,7 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
</p>
</div>
)}
{auction.status === 'Completed' && auction.winner && (
{effectiveStatus === 'Completed' && auction.winner && (
<div>
<p style={{ margin: 0, color: COLOR.muted, fontSize: '0.75rem' }}>
Winner
Expand All @@ -146,19 +177,32 @@ export const DutchAuctionCard: React.FC<DutchAuctionCardProps> = ({ auction, onP
)}
</div>

{auction.status === 'Active' && (
{effectiveStatus === 'Active' && (
<div style={{ marginTop: '1rem' }}>
<PendingButton
onClick={() => onPurchase?.(auction.id, currentPrice)}
pending={isPurchasing}
pendingLabel="Processing purchase…"
onClick={handlePurchase}
style={{ width: '100%' }}
>
Purchase Now for {fmt(currentPrice)}
</PendingButton>
</div>
)}

{isEnded && auction.status === 'Active' && (
<div
style={{ marginTop: '1rem' }}
role="status"
aria-live="polite"
>
<p style={{ margin: 0, color: COLOR.muted, fontSize: '0.85rem' }}>
This auction has ended.
</p>
</div>
)}
</div>
</div>
</div>
);
};

212 changes: 212 additions & 0 deletions src/components/__tests__/DutchAuctionCard.close-race.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { DutchAuctionCard } from '../DutchAuctionCard';
import type { DutchAuction } from '../../types/dutchAuction';

const now = Date.now();

function makeAuction(overrides: Partial<DutchAuction> = {}): DutchAuction {
return {
id: 'DA-TEST',
nft: {
id: 'NFT-TEST',
name: 'Test NFT',
description: 'A test auction',
image: 'https://test/img.png',
collection: 'Test Collection',
tokenId: '1',
},
seller: 'TEST...SELLER',
startPrice: 1000,
floorPrice: 100,
startTime: new Date(now - 3_600_000).toISOString(),
endTime: new Date(now + 3_600_000).toISOString(),
duration: 7200,
status: 'Active',
...overrides,
};
}

beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.useRealTimers();
});

describe('DutchAuctionCard — auction close race handling (issue #942)', () => {
it('shows Active status and Purchase button when auction is live', () => {
const auction = makeAuction();
render(<DutchAuctionCard auction={auction} />);

expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Purchase Now/ })).toBeInTheDocument();
});

it('clears the interval and removes Purchase button when auction ends mid-tick', () => {
const auction = makeAuction({
endTime: new Date(now + 2_000).toISOString(),
});
const onPurchase = vi.fn();

render(<DutchAuctionCard auction={auction} onPurchase={onPurchase} />);

expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Purchase Now/ })).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(3_000);
});

expect(screen.getByText('Completed')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Purchase Now/ })).not.toBeInTheDocument();
});

it('shows "This auction has ended" notice when an Active auction expires', () => {
const auction = makeAuction({
status: 'Active',
endTime: new Date(now + 1_000).toISOString(),
});

render(<DutchAuctionCard auction={auction} />);

act(() => {
vi.advanceTimersByTime(2_000);
});

expect(screen.getByText('This auction has ended.')).toBeInTheDocument();
});

it('does not show ended notice for auctions already Completed', () => {
const auction = makeAuction({
status: 'Completed',
winner: 'JABCDEF...98765',
finalPrice: 225,
endTime: new Date(now - 3_600_000).toISOString(),
});

render(<DutchAuctionCard auction={auction} />);

expect(screen.queryByText('This auction has ended.')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Purchase Now/ })).not.toBeInTheDocument();
});

it('stops the interval after the auction ends — no leaked timers', () => {
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
const auction = makeAuction({
endTime: new Date(now + 1_500).toISOString(),
});

render(<DutchAuctionCard auction={auction} />);

act(() => {
vi.advanceTimersByTime(2_000);
});

expect(clearIntervalSpy).toHaveBeenCalled();
clearIntervalSpy.mockRestore();
});

it('disables purchase during async operation and re-enables after', async () => {
let resolvePurchase!: () => void;
const onPurchase = vi.fn(
() => new Promise<void>((r) => { resolvePurchase = r; }),
);
const auction = makeAuction();

render(<DutchAuctionCard auction={auction} onPurchase={onPurchase} />);

const button = screen.getByRole('button', { name: /Purchase Now/ });
expect(button).toBeEnabled();

act(() => {
button.click();
});

expect(button).toBeDisabled();
expect(button).toHaveTextContent('Processing purchase…');

await act(async () => {
resolvePurchase();
});

expect(button).toBeEnabled();
expect(button).toHaveTextContent(/Purchase Now/);
});

it('prevents double-click purchase when first is in-flight', async () => {
let resolvePurchase!: () => void;
const onPurchase = vi.fn(
() => new Promise<void>((r) => { resolvePurchase = r; }),
);
const auction = makeAuction();

render(<DutchAuctionCard auction={auction} onPurchase={onPurchase} />);

const button = screen.getByRole('button', { name: /Purchase Now/ });

act(() => {
button.click();
});
act(() => {
button.click();
});

expect(onPurchase).toHaveBeenCalledTimes(1);

await act(async () => {
resolvePurchase();
});
});

it('does not fire purchase when auction has ended', async () => {
const onPurchase = vi.fn();
const auction = makeAuction({
endTime: new Date(now + 500).toISOString(),
});

render(<DutchAuctionCard auction={auction} onPurchase={onPurchase} />);

act(() => {
vi.advanceTimersByTime(1_000);
});

expect(screen.queryByRole('button', { name: /Purchase Now/ })).not.toBeInTheDocument();
expect(onPurchase).not.toHaveBeenCalled();
});

it('hides Time Left section after auction expires', () => {
const auction = makeAuction({
endTime: new Date(now + 1_000).toISOString(),
});

render(<DutchAuctionCard auction={auction} />);

expect(screen.getByText('Time Left')).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(2_000);
});

expect(screen.queryByText('Time Left')).not.toBeInTheDocument();
});

it('shows live countdown that decrements while auction is active', () => {
const auction = makeAuction({
endTime: new Date(now + 3_661_000).toISOString(), // 1h 1m 1s
});

render(<DutchAuctionCard auction={auction} />);

const timeLeftEl = screen.getByText(/^\d{2}:\d{2}:\d{2}$/);
const initialText = timeLeftEl.textContent;

act(() => {
vi.advanceTimersByTime(5_000);
});

const updatedText = screen.getByText(/^\d{2}:\d{2}:\d{2}$/).textContent;
expect(updatedText).not.toBe(initialText);
});
});
Loading