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
117 changes: 117 additions & 0 deletions src/components/common/AuctionBidHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Trophy } from 'lucide-react';
import type { AuctionBidEntry } from '@/services/course.service';
import { useRelativeTime } from '@/hooks/useRelativeTime';
import {
formatAuctionBidAmount,
rankAuctionBids,
shortenWalletAddress,
} from '@/utils/auctionBid.utils';

interface AuctionBidHistoryProps {
/** Live bid history (newest first is not required — rank handles it). */
bids: AuctionBidEntry[];
/** Connected wallet to highlight the caller's own bids. */
walletAddress?: string | null;
}

interface BidRowProps {
rank: number;
bid: AuctionBidEntry;
isMine: boolean;
}

function BidRow({ rank, bid, isMine }: BidRowProps) {
const placedAtLabel = useRelativeTime(bid.placedAt);
const bidderLabel = bid.bidderName || shortenWalletAddress(bid.bidderAddress);

return (
<li
data-testid="auction-bid-row"
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 ${
isMine ? 'bg-amber-500/10 ring-1 ring-inset ring-amber-400/20' : ''
}`}
>
<span
className={`flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold tabular-nums ${
rank === 1
? 'bg-amber-400/20 text-amber-300'
: rank === 2
? 'bg-white/10 text-white/70'
: 'bg-white/5 text-white/40'
}`}
aria-hidden="true"
>
{rank === 1 ? <Trophy className="size-3.5" /> : rank}
</span>
<span
className="min-w-0 flex-1 truncate font-jakarta text-sm font-semibold text-white"
title={bid.bidderAddress}
data-testid="auction-bid-bidder"
>
{bidderLabel}
{isMine && (
<span className="ml-2 rounded bg-amber-400/20 px-1.5 py-0.5 text-[0.65rem] font-bold uppercase tracking-wider text-amber-300">
You
</span>
)}
</span>
<span className="text-xs text-white/40" aria-hidden="true">
{placedAtLabel}
</span>
<span
className="font-grotesque text-sm font-black tabular-nums text-amber-400"
data-testid="auction-bid-amount"
>
{formatAuctionBidAmount(bid.amount)}
</span>
</li>
);
}

/**
* Leaderboard of the bids placed during a key's pre-launch auction (#924).
* Live-updating via the parent's polling hook; the caller's own bids are
* highlighted so the "you've been outbid" context is obvious.
*/
const AuctionBidHistory: React.FC<AuctionBidHistoryProps> = ({
bids,
walletAddress,
}) => {
const ranked = rankAuctionBids(bids);
const normalizedWallet = walletAddress?.trim().toLowerCase();

if (ranked.length === 0) {
return (
<div
className="rounded-xl border border-dashed border-white/10 px-4 py-6 text-center"
data-testid="auction-bid-history-empty"
>
<p className="font-jakarta text-sm font-semibold text-white/70">
No bids yet
</p>
<p className="mt-1 text-xs text-white/40">
Be the first to bid and lock in the leading position.
</p>
</div>
);
}

return (
<div data-testid="auction-bid-history">
<ol className="space-y-1">
{ranked.map((bid, index) => (
<BidRow
key={bid.id}
rank={index + 1}
bid={bid}
isMine={
bid.bidderAddress.trim().toLowerCase() === normalizedWallet
}
/>
))}
</ol>
</div>
);
};

export default AuctionBidHistory;
191 changes: 191 additions & 0 deletions src/components/common/AuctionBidPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { useAccount } from 'wagmi';
import { Button } from '@/components/ui/button';
import showToast from '@/utils/toast.util';
import { usePlaceAuctionBidMutation } from '@/hooks/useCreatorContractActions';
import {
computeMinimumNextBid,
formatAuctionBidAmount,
resolveAuctionMinIncrement,
validateAuctionBid,
} from '@/utils/auctionBid.utils';

const fieldClass =
'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50';

interface AuctionBidPanelProps {
/** Key being bid on — passed through to the `place_bid` contract call. */
creatorId: string;
/** Current highest bid in XLM (nullable when the auction has no bids yet). */
highestBid?: number | null;
/** Contract-specified minimum increment in XLM (nullable → default). */
minIncrement?: number | null;
/** Fixed early-access price in XLM, used as the starting bid floor. */
startingPrice?: number | null;
/** Disables bidding (e.g. once the auction window has closed). */
disabled?: boolean;
}

/**
* Pre-launch auction bid input for the key detail page (#924).
*
* Shows the current highest bid, the minimum increment and the minimum
* acceptable bid, then validates the entered amount against that floor before
* submitting `place_bid`. Requires a connected, non-mismatched wallet.
*/
const AuctionBidPanel: React.FC<AuctionBidPanelProps> = ({
creatorId,
highestBid,
minIncrement,
startingPrice,
disabled = false,
}) => {
const { isConnected, address } = useAccount();
const mutation = usePlaceAuctionBidMutation(creatorId);

const [bidInput, setBidInput] = useState('');
const [showError, setShowError] = useState(false);

// Clear the stale error/input once the highest bid moves (a poll picked up
// a higher bid) so the floor copy stays honest.
useEffect(() => {
setShowError(false);
}, [highestBid]);

const minimumNextBid = computeMinimumNextBid({
highestBid,
minIncrement,
startingPrice,
});
const increment = resolveAuctionMinIncrement(minIncrement);

const { error: errorMessage, value } = validateAuctionBid(bidInput, {
highestBid,
minIncrement,
startingPrice,
});
const hasError = showError && !value;

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (mutation.isPending || disabled) return;

if (!isConnected || !address) {
showToast.error('Connect your wallet to place a bid');
return;
}

if (value == null) {
setShowError(true);
return;
}

mutation.mutate(value, {
onSuccess: () => setBidInput(''),
});
};

const displayHighest =
highestBid != null ? formatAuctionBidAmount(highestBid) : 'No bids yet';

return (
<form
onSubmit={handleSubmit}
className="space-y-4"
noValidate
data-testid="auction-bid-panel"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/50">
Current highest bid
</p>
<p
className="mt-1 font-grotesque text-sm font-black text-amber-400"
data-testid="auction-highest-bid"
>
{displayHighest}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/50">
Minimum increment
</p>
<p
className="mt-1 font-grotesque text-sm font-black text-white"
data-testid="auction-min-increment"
>
{formatAuctionBidAmount(increment)}
</p>
</div>
<div className="rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-amber-400/80">
Minimum next bid
</p>
<p
className="mt-1 font-grotesque text-sm font-black text-amber-300"
data-testid="auction-min-next-bid"
>
{formatAuctionBidAmount(minimumNextBid)}
</p>
</div>
</div>

<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<label htmlFor="auction-bid" className="sr-only">
Your bid in XLM
</label>
<input
id="auction-bid"
data-testid="auction-bid-input"
type="number"
inputMode="decimal"
min={0}
step="0.01"
className={fieldClass}
placeholder={`Bid at least ${formatAuctionBidAmount(
minimumNextBid
)}`}
value={bidInput}
onChange={e => setBidInput(e.target.value)}
disabled={mutation.isPending || disabled}
aria-invalid={hasError ? 'true' : undefined}
aria-describedby="auction-bid-hint"
/>
<Button
type="submit"
data-testid="auction-bid-submit"
disabled={mutation.isPending || disabled || (hasError && !value)}
>
{mutation.isPending
? 'Placing bid…'
: isConnected
? 'Place Bid'
: 'Connect wallet to bid'}
</Button>
</div>

<p
id="auction-bid-hint"
className="text-xs text-white/40"
data-testid="auction-bid-helper"
>
Your bid must exceed the current highest bid by the minimum
increment. The leading bid wins the allocation when the auction
closes.
</p>

{hasError && errorMessage && (
<p
role="alert"
data-testid="auction-bid-error"
className="text-xs text-red-400"
>
{errorMessage}
</p>
)}
</form>
);
};

export default AuctionBidPanel;
54 changes: 54 additions & 0 deletions src/components/common/AuctionCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';

interface AuctionCountdownProps {
/**
* Human-readable time remaining until the auction closes, computed by
* `getAuctionCountdownState`. `null` when the key has no closing
* timestamp, in which case nothing is rendered.
*/
label?: string | null;
/** Whether the auction window has already closed. */
isEnded?: boolean;
className?: string;
}

/**
* Live pill showing time remaining until the pre-launch auction closes (#924).
* Pure presentational: the ticking state comes from `useAuctionPhase` so the
* surrounding auction section and the page react to the same instant.
*/
const AuctionCountdown: React.FC<AuctionCountdownProps> = ({
label,
isEnded = false,
className,
}) => {
if (label == null) return null;

return (
<p
role="status"
aria-live="polite"
aria-label={isEnded ? 'Auction closed' : `Auction closes in ${label}`}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold',
isEnded
? 'border-white/10 bg-white/[0.04] text-white/50'
: 'border-amber-400/30 bg-amber-500/10 text-amber-200',
className
)}
>
<Timer className="size-3.5 shrink-0" aria-hidden="true" />
{isEnded ? (
'Auction closed'
) : (
<span>
Auction closes in{' '}
<span className="font-mono tabular-nums">{label}</span>
</span>
)}
</p>
);
};

export default AuctionCountdown;
Loading
Loading