diff --git a/src/components/common/AuctionBidHistory.tsx b/src/components/common/AuctionBidHistory.tsx new file mode 100644 index 00000000..2c3a0951 --- /dev/null +++ b/src/components/common/AuctionBidHistory.tsx @@ -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 ( +
  • + + + {bidderLabel} + {isMine && ( + + You + + )} + + + + {formatAuctionBidAmount(bid.amount)} + +
  • + ); +} + +/** + * 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 = ({ + bids, + walletAddress, +}) => { + const ranked = rankAuctionBids(bids); + const normalizedWallet = walletAddress?.trim().toLowerCase(); + + if (ranked.length === 0) { + return ( +
    +

    + No bids yet +

    +

    + Be the first to bid and lock in the leading position. +

    +
    + ); + } + + return ( +
    +
      + {ranked.map((bid, index) => ( + + ))} +
    +
    + ); +}; + +export default AuctionBidHistory; \ No newline at end of file diff --git a/src/components/common/AuctionBidPanel.tsx b/src/components/common/AuctionBidPanel.tsx new file mode 100644 index 00000000..5cc480af --- /dev/null +++ b/src/components/common/AuctionBidPanel.tsx @@ -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 = ({ + 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 ( +
    +
    +
    +

    + Current highest bid +

    +

    + {displayHighest} +

    +
    +
    +

    + Minimum increment +

    +

    + {formatAuctionBidAmount(increment)} +

    +
    +
    +

    + Minimum next bid +

    +

    + {formatAuctionBidAmount(minimumNextBid)} +

    +
    +
    + +
    + + setBidInput(e.target.value)} + disabled={mutation.isPending || disabled} + aria-invalid={hasError ? 'true' : undefined} + aria-describedby="auction-bid-hint" + /> + +
    + +

    + Your bid must exceed the current highest bid by the minimum + increment. The leading bid wins the allocation when the auction + closes. +

    + + {hasError && errorMessage && ( +

    + {errorMessage} +

    + )} +
    + ); +}; + +export default AuctionBidPanel; \ No newline at end of file diff --git a/src/components/common/AuctionCountdown.tsx b/src/components/common/AuctionCountdown.tsx new file mode 100644 index 00000000..774d6a88 --- /dev/null +++ b/src/components/common/AuctionCountdown.tsx @@ -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 = ({ + label, + isEnded = false, + className, +}) => { + if (label == null) return null; + + return ( +

    +

    + ); +}; + +export default AuctionCountdown; \ No newline at end of file diff --git a/src/components/common/AuctionPhaseSection.tsx b/src/components/common/AuctionPhaseSection.tsx new file mode 100644 index 00000000..0a7878a0 --- /dev/null +++ b/src/components/common/AuctionPhaseSection.tsx @@ -0,0 +1,191 @@ +import { useAccount } from 'wagmi'; +import type { AuctionBidEntry } from '@/services/course.service'; +import { useAuctionBids } from '@/hooks/useAuctionBids'; +import { useAuctionPhase } from '@/hooks/useAuctionPhase'; +import AuctionBidHistory from '@/components/common/AuctionBidHistory'; +import AuctionBidPanel from '@/components/common/AuctionBidPanel'; +import AuctionCountdown from '@/components/common/AuctionCountdown'; +import { + findWalletBestBid, + formatAuctionBidAmount, + isWalletLeadingAuction, + resolveHighestBid, +} from '@/utils/auctionBid.utils'; + +export interface AuctionPhaseSectionProps { + creatorId: string; + /** Fixed early-access price in XLM; used as the starting bid floor. */ + auctionPrice?: number | null; + /** Number of keys allocated to the auction. */ + auctionSupply?: number | null; + /** Keys sold through the auction so far. */ + auctionSold?: number | null; + /** ISO timestamp when the bidding window closes and the curve activates. */ + auctionEndsAt?: string | null; + /** Minimum increment a new bid must beat the current high bid by (XLM). */ + auctionMinIncrement?: number | null; + /** Explicit current highest bid in XLM, when the backend provides it. */ + auctionHighestBid?: number | null; + /** Initial bid history from the creator detail payload. */ + auctionBids?: AuctionBidEntry[]; +} + +/** + * Pre-launch auction phase UI for a creator key (#924). + * + * Composes the countdown to auction close, the bidding panel (current highest + * bid + minimum increment + validated bid submission), the live top-bidders + * history, the outbid notice when the connected wallet is no longer leading, + * and the transition banner once the auction closes and the bonding curve + * activates. Renders nothing for keys without an auction configured. + */ +const AuctionPhaseSection: React.FC = ({ + creatorId, + auctionPrice, + auctionSupply, + auctionSold, + auctionEndsAt, + auctionMinIncrement, + auctionHighestBid, + auctionBids, +}) => { + const config = { auctionPrice, auctionSupply, auctionSold, auctionEndsAt }; + const { phase, countdown } = useAuctionPhase(config); + const { address } = useAccount(); + const { bids } = useAuctionBids(creatorId, auctionBids); + + if (phase === 'inactive') return null; + + const highestBid = resolveHighestBid(bids, auctionHighestBid); + const isLeading = isWalletLeadingAuction(bids, address); + const walletBest = findWalletBestBid(bids, address); + + const isActive = phase === 'active'; + + const supply = auctionSupply && auctionSupply > 0 ? auctionSupply : null; + const sold = + auctionSold != null && auctionSold >= 0 && supply != null + ? Math.min(auctionSold, supply) + : null; + const remaining = supply != null && sold != null ? supply - sold : null; + const progressPercent = + supply != null && sold != null ? Math.min(100, (sold / supply) * 100) : 0; + + const isOutbid = Boolean(walletBest && !isLeading); + + return ( +
    +
    +
    +

    + Auction Phase +

    +

    + Pre-Launch Auction +

    +

    + This key starts trading on the bonding curve when the auction + closes. The leading bid secures the first allocation. +

    +
    + +
    + + {supply != null && remaining != null && ( +
    +
    +

    + Early access allocation +

    +

    + {remaining}{' '} + of {supply} keys left +

    +
    +
    +
    +
    +
    + )} + + {isActive ? ( + <> + {isOutbid && highestBid != null && ( +
    +

    + You've been outbid +

    +

    + Your bid is no longer the highest. The current lead is{' '} + + {formatAuctionBidAmount(highestBid)} + + — place a higher bid to retake the lead. +

    +
    + )} + +
    + +
    + +
    +

    + Top bids +

    + +
    + + ) : ( +
    +

    + Auction closed — the bonding curve is now live +

    +

    + Trading has started at the bonding curve price below. New keys + can be bought and sold without a bid. + {highestBid != null && ( + + Winning bid:{' '} + + {formatAuctionBidAmount(highestBid)} + + + )} +

    +
    + )} +
    + ); +}; + +export default AuctionPhaseSection; \ No newline at end of file diff --git a/src/hooks/useAuctionBids.ts b/src/hooks/useAuctionBids.ts new file mode 100644 index 00000000..d3c0aa02 --- /dev/null +++ b/src/hooks/useAuctionBids.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + courseService, + type AuctionBidEntry, +} from '@/services/course.service'; + +// #924 — the bid history must stay live while the auction runs. Polling every +// 8s keeps the leaderboard (and the "you've been outbid" state) current +// without a manual refresh, mirroring the marketplace price polling in #918. +const AUCTION_BID_POLL_INTERVAL_MS = 8_000; + +/** + * Live pre-launch auction bid history for a key (#924). + * + * Polls `GET /keys/:keyId/auction/bids` on a fixed cadence so the bid list and + * the current leader reflect new bids shortly after they land. `fallback` + * lets callers seed the list from the creator detail payload so the panel is + * populated even before the first poll resolves. When the endpoint is not + * available yet the query simply reports no data — components should fall + * back to `fallback`/derived values rather than erroring. + */ +export function useAuctionBids( + creatorId: string, + fallback: AuctionBidEntry[] | null | undefined = [] +) { + const query = useQuery({ + queryKey: queryKeys.creators.auctionBids(creatorId), + queryFn: () => courseService.getAuctionBids(creatorId), + enabled: Boolean(creatorId), + refetchInterval: AUCTION_BID_POLL_INTERVAL_MS, + }); + + const liveBids = query.data ?? []; + const bids = liveBids.length > 0 ? liveBids : (fallback ?? []); + + return { + bids, + isLoading: query.isLoading, + refetch: query.refetch, + }; +} \ No newline at end of file diff --git a/src/hooks/useAuctionPhase.ts b/src/hooks/useAuctionPhase.ts new file mode 100644 index 00000000..7a56546f --- /dev/null +++ b/src/hooks/useAuctionPhase.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { + getAuctionCountdownState, + getAuctionPhaseStatus, + type AuctionCountdownState, + type AuctionPhaseConfig, + type AuctionPhaseStatus, +} from '@/utils/auctionBid.utils'; + +/** + * Tracks the live pre-launch auction state for a key (#924). + * + * Re-evaluates the phase every second so the countdown is accurate to the + * auction end timestamp and the UI flips to the "auction ended / bonding + * curve live" transition at the right moment without a manual refresh. The + * interval is only running while the key actually has an auction configured. + */ +export function useAuctionPhase(config: AuctionPhaseConfig) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + const hasAuction = + getAuctionPhaseStatus(config, Date.now()) !== 'inactive'; + + useEffect(() => { + if (!hasAuction) return; + + setNowMs(Date.now()); + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [ + hasAuction, + config.auctionEndsAt, + config.auctionPrice, + config.auctionSupply, + config.auctionSold, + ]); + + const phase: AuctionPhaseStatus = getAuctionPhaseStatus(config, nowMs); + const countdown: AuctionCountdownState = getAuctionCountdownState( + config.auctionEndsAt, + nowMs + ); + + return { phase, countdown }; +} \ No newline at end of file diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index 5ab34575..90b251cf 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -103,6 +103,34 @@ export function useCancelAuctionMutation(creatorId: string) { }); } +/** + * Places a bid during the pre-launch auction window (#924) via the contract's + * `place_bid` function (`{ creatorId, amount }`). On success the creator + * detail (auction price/sold) and the live bid history caches are invalidated + * so the panel and leaderboard reflect the new bid. + */ +export function usePlaceAuctionBidMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'place_bid', creatorId], + mutationFn: (amount: number) => + submitContractCall('place_bid', { creatorId, amount }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(creatorId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.auctionBids(creatorId), + }); + showToast.success('Bid placed'); + }, + }); +} + export function useSetLaunchPenaltyMutation(creatorId: string) { const queryClient = useQueryClient(); diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 80a579b3..cf734a27 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -32,6 +32,8 @@ export const queryKeys = { twap: (creatorId: string) => ['creators', creatorId, 'twap', '24h'] as const, stats: (creatorId: string) => ['creators', creatorId, 'stats'] as const, + auctionBids: (creatorId: string) => + ['creators', creatorId, 'auction-bids'] as const, uniqueTraders: (creatorId: string) => ['creators', creatorId, 'unique-traders'] as const, curveConfig: (creatorId: string) => @@ -174,4 +176,3 @@ export const queryKeys = { ['creatorRevenue', creatorId, 'withdrawals'] as const, }, } as const; - diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index b38d8181..ab016cb0 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -6,9 +6,12 @@ import { useRecentlyViewed } from '@/hooks/useRecentlyViewed'; import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator'; import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb'; import CreatorProfileHeader from '@/components/common/CreatorProfileHeader'; +import CreatorProfileInfoGrid from '@/components/common/CreatorProfileInfoGrid'; import CreatorActivityFeed from '@/components/common/CreatorActivityFeed'; +import CreatorProfileStaleIndicator from '@/components/common/CreatorProfileStaleIndicator'; import CreatorProfileStatRow from '@/components/common/CreatorProfileStatRow'; import BondingCurveChart from '@/components/common/BondingCurveChart'; +import KeySimulationTool from '@/components/common/KeySimulationTool'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import DeprecationNotice from '@/components/common/DeprecationNotice'; @@ -16,18 +19,25 @@ import SubscriptionAccessGate from '@/components/common/SubscriptionAccessGate'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; import { Button } from '@/components/ui/button'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; -import { formatNumber } from '@/utils/numberFormat.utils'; +import { bpsToPercent, formatNumber } from '@/utils/numberFormat.utils'; import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice, } from '@/utils/keyPriceDisplay.utils'; +import { + resolveHighestBid, + formatAuctionBidAmount, +} from '@/utils/auctionBid.utils'; import KeyDetailPageErrorBoundary from '@/components/common/KeyDetailPageErrorBoundary'; import { ApiError } from '@/services/api.service'; import WatchlistButton from '@/components/common/WatchlistButton'; +import AuctionPhaseSection from '@/components/common/AuctionPhaseSection'; +import { useAuctionPhase } from '@/hooks/useAuctionPhase'; import { useNavigationTiming } from '@/hooks/useNavigationTiming'; import { useKeyHolders } from '@/hooks/useKeyHolders'; import { useProfileStore } from '@/hooks/useProfileStore'; import { useWalletHoldings, useTradeMutation } from '@/hooks/useWallet'; +import CoCreatorSection from '@/components/creator/CoCreatorSection'; import ShareTwitterButton from '@/components/common/ShareTwitterButton'; import { PriceHistoryChart } from '@/components/common/PriceHistoryChart'; import type { PriceHistoryInterval } from '@/services/course.service'; @@ -46,6 +56,7 @@ import { useKeyConfig } from '@/hooks/useKeyConfig'; import KeyStatsPanel from '@/components/common/KeyStatsPanel'; import Skeleton from '@/components/ui/skeleton'; import { Tooltip } from '@/components/ui/tooltip'; +import GraduatedCurveMilestoneChart from '@/components/common/GraduatedCurveMilestoneChart'; import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; import MergeProposalBanner from '@/components/common/MergeProposalBanner'; import KeyBuybackModal from '@/components/common/KeyBuybackModal'; @@ -56,6 +67,8 @@ import WhitelistStatusBadge from '@/components/common/WhitelistStatusBadge'; import { useTradeCooldownStatus, invalidateTradeCooldownStatus, resolveActiveTradeCooldown } from '@/hooks/useTradeCooldownStatus'; import { isActiveCooldown, type ActiveTradeCooldown } from '@/utils/tradeCooldown.utils'; import TradeCooldownButton from '@/components/common/TradeCooldownButton'; +import KeyHolderList from '@/components/common/KeyHolderList'; +import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; function CreatorDetailPageContent() { usePurchaseConfetti(); @@ -102,7 +115,8 @@ function CreatorDetailPageContent() { }); }, [creator, recordVisit]); - useKeyHolders(id || ''); + const { holders, hasNextPage, isFetchingNextPage, fetchNextPage } = + useKeyHolders(id || ''); // User holdings for Share to X button const profile = useProfileStore(state => state.profile); @@ -149,6 +163,16 @@ function CreatorDetailPageContent() { const performanceBond = performanceBondData ?? creator?.performanceBond ?? null; + + // Pre-launch auction phase (#924): a live tick that drives the countdown + // and flips the page back to the bonding curve view once the auction ends. + const { phase: auctionPhase } = useAuctionPhase({ + auctionPrice: creator?.auctionPrice ?? null, + auctionSupply: creator?.auctionSupply ?? null, + auctionSold: creator?.auctionSold ?? null, + auctionEndsAt: creator?.auctionEndsAt ?? null, + }); + // Whitelist gate check for early access keys (#1031) const isWhitelistGateActive = Boolean( creator?.isWhitelistEnabled ?? creator?.whitelistEnabled ?? false @@ -164,8 +188,9 @@ function CreatorDetailPageContent() { : false; const isLockedOut = isWhitelistGateActive && !isUserWhitelisted; + // Track stale data indicator - useCreatorProfileStaleIndicator( + const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( id || '', isFetching, () => refetch() @@ -255,12 +280,39 @@ function CreatorDetailPageContent() { throw error; } + const feeItems = [ + { + label: 'Creator fee', + value: bpsToPercent(creator.creatorFeeBps), + helperText: 'Fee paid directly to the creator on each trade.', + }, + { + label: 'Protocol fee', + value: bpsToPercent(creator.protocolFeeBps), + helperText: 'Fee paid to the platform for protocol maintenance.', + }, + ]; + // During the pre-launch auction phase (#808/#924) the bonding curve price + // is not live yet — surface the current highest bid (or early-access price). + const auctionLeadBid = + auctionPhase === 'active' + ? resolveHighestBid(creator.auctionBids, creator.auctionHighestBid) + : null; + const auctionStatValue = + auctionLeadBid != null + ? formatAuctionBidAmount(auctionLeadBid) + : creator.auctionPrice != null && creator.auctionPrice > 0 + ? formatAuctionBidAmount(creator.auctionPrice) + : '—'; const statItems = [ { - label: 'Current Price', - value: formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)), + label: auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price', + value: + auctionPhase === 'active' + ? auctionStatValue + : formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)), }, { label: 'Key Supply', @@ -378,6 +430,7 @@ function CreatorDetailPageContent() {
    + {/* Key Stats Panel (#952) */} )} + + + {/* Pre-Launch Auction Phase (#924) */} + {auctionPhase !== 'inactive' && ( + + )} + + {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} {userAddress && !isTradeCooldownActive && ( @@ -475,6 +545,7 @@ function CreatorDetailPageContent() { userHoldingsCount={holdingsCount} /> + {isTwapLoading ? (
    + + {/* Buy Simulation Tool */} + + + + {/* TWAP for the live curve — not meaningful during the pre-launch auction */} + {auctionPhase !== 'active' && + (isTwapLoading ? ( +
    +
    +
    + ) : twapPrice != null ? ( +
    +
    +
    +
    + TWAP (24h) + + + +
    +
    {formatDisplayKeyPrice(twapPrice)}
    +
    + {twapDelta != null && {twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot} +
    +
    + ) : null)} + + {/* Staking Rewards */} + + + {/* Price Chart — hidden during the pre-launch auction phase (#924) and + shown again once the auction closes and the curve activates */} + {auctionPhase !== 'active' && ( +
    +

    + Price Curve +

    + +
    + )} + + {/* Buy Simulation Tool — likewise only once the curve is live */} + {auctionPhase !== 'active' && ( + + )} + + + {/* Holder Concentration */} +
    +

    + Holder Concentration +

    + +
    + {/* Fee Structure */} +
    +
    +

    + Fee Structure +

    + +
    + +
    + {/* Co-Creator Section */}{' '} + + {/* Key Holders */} +
    +

    + Key Holders +

    + { + void fetchNextPage(); + }} + /> +
    {/* Gated Content */}

    Exclusive Content

    - setBuyDialogOpen(true)} > diff --git a/src/pages/HolderLeaderboardPage.tsx b/src/pages/HolderLeaderboardPage.tsx index ee9af06c..866433ad 100644 --- a/src/pages/HolderLeaderboardPage.tsx +++ b/src/pages/HolderLeaderboardPage.tsx @@ -134,7 +134,7 @@ export default function HolderLeaderboardPage() { const queryClient = useQueryClient(); const profile = useProfileStore(state => state.profile); const userAddress = profile?.id?.toLowerCase(); - + const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); const { @@ -172,7 +172,7 @@ export default function HolderLeaderboardPage() { // Rank holders and limit to top 100 const rankedHolders = rankKeyHolders(holders).slice(0, TOP_HOLDERS_LIMIT); - + // Find connected wallet rank const connectedWalletRank = userAddress ? rankedHolders.findIndex(h => h.walletAddress?.toLowerCase() === userAddress) diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 1aedfb10..038188a3 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -58,6 +58,25 @@ export interface Course { auctionSupply?: number; /** Keys sold through the auction so far. */ auctionSold?: number; + /** + * ISO timestamp for when the pre-launch bidding window closes and the + * key goes live on the bonding curve (#924). When absent, the auction + * ends once `auctionSold` reaches `auctionSupply`. + */ + auctionEndsAt?: string; + /** + * Minimum amount (XLM) a new bid must exceed the current highest bid by + * (#924). When absent, a 5% increment over the highest bid is assumed. + */ + auctionMinIncrement?: number; + /** + * Current highest bid in XLM (#924). When `auctionBids` is present this + * is usually derived from the history instead; the explicit field is the + * source of truth for auctions whose history hasn't been loaded yet. + */ + auctionHighestBid?: number; + /** Pre-launch auction bid history, newest first (#924). */ + auctionBids?: AuctionBidEntry[]; /** * Early-sell penalty in basis points (0–2000 = 0%–20%). * Applied to sells within the first 7 days after key creation. @@ -375,6 +394,21 @@ export interface KeyStats { twap24h: number | null; } + +/** Single bid placed during a key's pre-launch auction window (#924). */ +export interface AuctionBidEntry { + /** Unique bid id from the contract. */ + id: string; + /** Wallet that placed the bid. */ + bidderAddress: string; + /** Optional display name for the bidder. */ + bidderName?: string; + /** Bid amount in XLM. */ + amount: number; + /** ISO timestamp when the bid was placed. */ + placedAt: string; +} + /** * Unique trader count for a creator key (#1020): distinct wallets that have * bought or sold the key at least once. @@ -384,6 +418,7 @@ export interface KeyUniqueTraders { uniqueTraders: number | null; /** Unique trader count as of 24 hours ago, used for the trend indicator. */ uniqueTraders24hAgo: number | null; + } class CourseService extends BaseApiService { @@ -531,6 +566,23 @@ class CourseService extends BaseApiService { } } + + /** + * Get the live pre-launch auction bid history for a key (#924) — + * GET /keys/:keyId/auction/bids. Returns the bids newest first so the + * client can render the current leader without extra sorting. + */ + async getAuctionBids(keyId: string): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/keys/${keyId}/auction/bids`); + return response.data.data ?? []; + } catch (error) { + throw this.handleError(error); + } + } + // Get the unique trader count - GET /keys/:keyId/unique-traders async getKeyUniqueTraders(keyId: string): Promise { try { diff --git a/src/utils/auctionBid.utils.ts b/src/utils/auctionBid.utils.ts new file mode 100644 index 00000000..f0bfd042 --- /dev/null +++ b/src/utils/auctionBid.utils.ts @@ -0,0 +1,239 @@ +/** + * Pre-launch auction bidding state, countdown and validation helpers (#924). + * + * These helpers power the auction phase UI on the key detail page: deciding + * whether a key is in its pre-launch bidding window, computing the minimum + * acceptable bid, validating raw bid input, and detecting whether the + * connected wallet is still the leading bidder. + */ + +import type { AuctionBidEntry } from '@/services/course.service'; +import { formatDropTimeRemaining } from '@/utils/dropCountdown.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; + +/** Fallback minimum increment (XLM) when the contract omits one. */ +export const DEFAULT_AUCTION_MIN_INCREMENT = 0.05; + +export type AuctionPhaseStatus = 'inactive' | 'active' | 'ended'; + +export interface AuctionPhaseConfig { + auctionPrice?: number | null; + auctionSupply?: number | null; + auctionSold?: number | null; + auctionEndsAt?: string | null; +} + +function isFiniteNumber(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function toMs(value: string | null | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + +/** + * Resolves the current pre-launch auction phase for a key. + * + * A key is considered to have an auction when it has a fixed price / + * supply allocation (`auctionPrice`/`auctionSupply`) or a closing timestamp + * (`auctionEndsAt`). The phase is `ended` once the allocation is sold out or + * the closing timestamp passes; everything else before that is `active`. + */ +export function getAuctionPhaseStatus( + config: AuctionPhaseConfig, + nowMs: number = Date.now() +): AuctionPhaseStatus { + const hasAuction = + (config.auctionPrice ?? 0) > 0 || + (config.auctionSupply ?? 0) > 0 || + toMs(config.auctionEndsAt) != null; + if (!hasAuction) return 'inactive'; + + const endMs = toMs(config.auctionEndsAt); + if ( + isFiniteNumber(config.auctionSupply) && + isFiniteNumber(config.auctionSold) && + config.auctionSold >= config.auctionSupply + ) { + return 'ended'; + } + if (endMs != null && nowMs >= endMs) return 'ended'; + + return 'active'; +} + +export interface AuctionCountdownState { + /** Whether the auction window has closed. */ + isEnded: boolean; + /** Human-readable time remaining, or null when no closing timestamp exists. */ + label: string | null; + /** Milliseconds remaining until the auction closes (clamped to zero). */ + remainingMs: number; +} + +/** Countdown state relative to `nowMs`; accurate to the auction end timestamp. */ +export function getAuctionCountdownState( + endIso: string | null | undefined, + nowMs: number = Date.now() +): AuctionCountdownState { + const endMs = toMs(endIso); + if (endMs == null) { + return { isEnded: false, label: null, remainingMs: 0 }; + } + const remainingMs = Math.max(0, endMs - nowMs); + return { + isEnded: remainingMs <= 0, + label: formatDropTimeRemaining(remainingMs), + remainingMs, + }; +} + +/** Resolve the min-increment helper value (XLM), with a sane default. */ +export function resolveAuctionMinIncrement( + minIncrement: number | null | undefined +): number { + return isFiniteNumber(minIncrement) + ? minIncrement + : DEFAULT_AUCTION_MIN_INCREMENT; +} + +/** + * The minimum amount a new bid must be (XLM). Without an existing highest + * bid this is the fixed early-access price (or the increment when that too + * is absent); once a bid exists it is `highest + increment`. + */ +export function computeMinimumNextBid( + config: { + highestBid?: number | null; + minIncrement?: number | null; + startingPrice?: number | null; + } +): number { + const { highestBid, minIncrement, startingPrice } = config; + const increment = resolveAuctionMinIncrement(minIncrement); + + if (isFiniteNumber(highestBid)) { + return highestBid + increment; + } + if (isFiniteNumber(startingPrice)) { + return startingPrice; + } + return increment; +} + +export interface AuctionBidValidation { + isValid: boolean; + /** Human-readable error shown under the bid input when invalid. */ + error: string | null; + /** Parsed numeric bid value when valid, otherwise null. */ + value: number | null; +} + +/** Formats a bid amount for display. */ +export function formatAuctionBidAmount(amount: number): string { + return `${formatNumber(amount, { + maximumFractionDigits: 4, + })} XLM`; +} + +/** Validates raw bid input against the minimum next bid. */ +export function validateAuctionBid( + raw: string, + ctx: { + highestBid?: number | null; + minIncrement?: number | null; + startingPrice?: number | null; + } +): AuctionBidValidation { + const trimmed = raw.trim(); + if (trimmed === '') { + return { isValid: false, error: 'Enter a bid amount', value: null }; + } + + const value = Number(trimmed); + if (!Number.isFinite(value) || value <= 0) { + return { isValid: false, error: 'Enter a bid greater than 0', value: null }; + } + + const minimum = computeMinimumNextBid(ctx); + if (value < minimum) { + return { + isValid: false, + error: `Minimum bid is ${formatAuctionBidAmount(minimum)}`, + value: null, + }; + } + + return { isValid: true, error: null, value }; +} + +/** + * The wallet's highest bid within the given history, or null when the wallet + * has not placed a bid yet. Addresses are compared case-insensitively. + */ +export function findWalletBestBid( + bids: AuctionBidEntry[] | null | undefined, + walletAddress: string | null | undefined +): AuctionBidEntry | null { + if (!walletAddress || !bids?.length) return null; + const normalized = walletAddress.trim().toLowerCase(); + + const walletBids = bids.filter( + bid => bid.bidderAddress.trim().toLowerCase() === normalized + ); + if (!walletBids.length) return null; + + return walletBids.reduce((best, bid) => + bid.amount > best.amount ? bid : best + ); +} + +/** The current highest bid across the history, or the explicit fallback. */ +export function resolveHighestBid( + bids: AuctionBidEntry[] | null | undefined, + explicitHighestBid?: number | null +): number | null { + let highest: number | null = null; + for (const bid of bids ?? []) { + if (isFiniteNumber(bid.amount) && (highest == null || bid.amount > highest)) { + highest = bid.amount; + } + } + return highest ?? (isFiniteNumber(explicitHighestBid) ? explicitHighestBid : null); +} + +/** + * Whether the connected wallet is currently the leading bidder. Bids are + * compared only against the actual history (never the explicit fallback), so + * a wallet without a recorded bid can never appear as the leader. + */ +export function isWalletLeadingAuction( + bids: AuctionBidEntry[] | null | undefined, + walletAddress: string | null | undefined +): boolean { + const walletBest = findWalletBestBid(bids, walletAddress); + if (!walletBest || !bids?.length) return false; + + return bids.every(bid => bid.amount <= walletBest.amount); +} + +/** Sorts bids highest-first for the leaderboard view. */ +export function rankAuctionBids( + bids: AuctionBidEntry[] | null | undefined +): AuctionBidEntry[] { + if (!bids?.length) return []; + return [...bids].sort((a, b) => b.amount - a.amount); +} + +/** Compact display form for a long wallet address. */ +export function shortenWalletAddress( + address: string, + prefixLength = 6, + suffixLength = 4 +): string { + const trimmed = address.trim(); + if (trimmed.length <= prefixLength + suffixLength + 1) return trimmed; + return `${trimmed.slice(0, prefixLength)}…${trimmed.slice(-suffixLength)}`; +} \ No newline at end of file