+ 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() {