From 6e6018812f99fcb4c7226ef45bb049b905fcc741 Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Tue, 29 Sep 2026 17:53:02 +0100 Subject: [PATCH 1/2] feat(keys): add early access whitelist status indicator and gating --- src/hooks/useUpdateCreatorProfile.ts | 23 ++- src/pages/CreatorDashboardPage.tsx | 139 ++++++++++--- src/pages/CreatorDetailPage.tsx | 193 +++++++++++++++--- .../CreatorDetailPage.integration.test.tsx | 145 ++++++++++++- src/services/course.service.ts | 13 +- 5 files changed, 448 insertions(+), 65 deletions(-) diff --git a/src/hooks/useUpdateCreatorProfile.ts b/src/hooks/useUpdateCreatorProfile.ts index f31ad4c9..c832f397 100644 --- a/src/hooks/useUpdateCreatorProfile.ts +++ b/src/hooks/useUpdateCreatorProfile.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { courseService, type Course } from '@/services/course.service'; import { queryKeys } from '@/lib/queryKeys'; +import { cacheManager } from '@/utils/cache.utils'; import showToast from '@/utils/toast.util'; interface UpdateCreatorProfileVariables { @@ -12,7 +13,7 @@ interface UpdateCreatorProfileVariables { * Mutation hook for updating a creator's profile information. * * On success: - * - Invalidates the creator profile query to refetch fresh data + * - Replaces the creator profile cache with the saved course * - Shows success toast: "Profile updated successfully" * - Toast auto-dismisses after 4 seconds * @@ -30,19 +31,25 @@ export function useUpdateCreatorProfile() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ creatorId, data }: UpdateCreatorProfileVariables) => { + mutationFn: async ({ + creatorId, + data, + }: UpdateCreatorProfileVariables) => { return courseService.updateCourse(creatorId, data); }, - onError: (error) => { + onError: error => { const errorMessage = error instanceof Error ? error.message : 'Failed to update profile'; showToast.error(errorMessage); }, - onSuccess: (_, variables) => { - // Invalidate the creator profile query to refetch fresh data - queryClient.invalidateQueries({ - queryKey: queryKeys.creators.detail(variables.creatorId), - }); + onSuccess: (updatedCourse, variables) => { + cacheManager.invalidate(`course_${variables.creatorId}`); + if (updatedCourse) { + queryClient.setQueryData( + queryKeys.creators.detail(variables.creatorId), + updatedCourse + ); + } // Show success toast that auto-dismisses after 4 seconds showToast.success('Profile updated successfully'); diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index 19a12556..9124e0dc 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -15,8 +15,12 @@ import { useSetMaxBuyQuantityMutation, useSetQuorumBpsMutation, } from '@/hooks/useCreatorContractActions'; -import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, +} from '@/utils/keyPriceDisplay.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; +import { useUpdateCreatorProfile } from '@/hooks/useUpdateCreatorProfile'; const TABS = [ { label: 'Overview', value: 'overview' }, @@ -44,6 +48,7 @@ export default function CreatorDashboardPage() { const setLaunchPenalty = useSetLaunchPenaltyMutation(id); const setMaxBuyQuantity = useSetMaxBuyQuantityMutation(id); const setQuorumBps = useSetQuorumBpsMutation(id); + const updateCreatorProfile = useUpdateCreatorProfile(); const setTab = (value: string) => { setSearchParams( @@ -70,9 +75,15 @@ export default function CreatorDashboardPage() { return (
-

Creator dashboard

-

- We couldn't load this creator's dashboard. Try again shortly. +

+ Creator dashboard +

+

+ We couldn't load this creator's dashboard. Try again + shortly.

@@ -91,7 +102,11 @@ export default function CreatorDashboardPage() {

- + {activeTab === 'overview' && (
- {formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator))} + {formatDisplayKeyPrice( + resolveCreatorKeyPriceStroops(creator) + )}
@@ -125,7 +142,9 @@ export default function CreatorDashboardPage() {
Category
-
{creator.category}
+
+ {creator.category} +
@@ -139,61 +158,132 @@ export default function CreatorDashboardPage() { aria-labelledby="profile-tab-settings" data-testid="dashboard-settings-panel" > -
+

Edit Profile

- Update the display name, bio and avatar stored with your key. + Update the display name, bio and avatar stored with your + key.

metadataMutation.mutate(change)} />
-
+
+

+ Early access +

+

+ Limit purchases to whitelisted wallets until public + launch. +

+ + {creator.publicLaunchDate && ( +

+ Public launch:{' '} + +

+ )} +
+ +

Auction Setup

- Set a fixed auction price and supply allocation before your key - goes live. + Set a fixed auction price and supply allocation before + your key goes live.

configureAuction.mutate(input)} onCancel={() => cancelAuction.mutate()} />
-
+

Launch Penalty

- Charge early sellers a percentage fee during the first 7 days - after key creation. + Charge early sellers a percentage fee during the first 7 + days after key creation.

setLaunchPenalty.mutate(penaltyBps)} + onSubmit={penaltyBps => + setLaunchPenalty.mutate(penaltyBps) + } />
-
+

Max Buy Per Transaction

- Limit how many keys a wallet can buy in a single transaction. + Limit how many keys a wallet can buy in a single + transaction.

-
+

Quorum Settings

- Set the minimum percentage of holders that must participate - in a vote for a proposal to pass. + Set the minimum percentage of holders that must + participate in a vote for a proposal to pass.

state.profile); - const userAddress = profile?.id; + const { address: connectedWalletAddress } = useAccount(); + const userAddress = connectedWalletAddress ?? profile?.id; const { data: holdings = [] } = useWalletHoldings(userAddress ?? ''); const userPosition = holdings.find(h => h.creatorId === (id || '')); const holdingsCount = userPosition?.quantity ?? 0; @@ -89,6 +94,8 @@ function CreatorDetailPageContent() { const nextBuyAllowedAt = userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); + const tradeMutation = useTradeMutation(connectedWalletAddress ?? ''); + const [tradeDialogOpen, setTradeDialogOpen] = useState(false); // Track stale data indicator const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( @@ -174,7 +181,8 @@ function CreatorDetailPageContent() { })); const spotPrice = resolveCreatorKeyPriceStroops(creator); const twapPrice = twap?.priceStroops ?? null; - const twapDelta = twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; + const twapDelta = + twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; const hasRealStakingData = creator.stakingPoolBalance != null || @@ -194,6 +202,28 @@ function CreatorDetailPageContent() { : 25, recentFeeInflow: 62, }; + const publicLaunchTimestamp = creator.publicLaunchDate + ? Date.parse(creator.publicLaunchDate) + : null; + const hasValidPublicLaunchDate = + publicLaunchTimestamp != null && Number.isFinite(publicLaunchTimestamp); + const isPublicLaunchPending = + hasValidPublicLaunchDate && publicLaunchTimestamp > Date.now(); + const isEarlyAccessRestricted = + creator.earlyAccessEnabled === true && + (!hasValidPublicLaunchDate || isPublicLaunchPending); + const isWhitelisted = Boolean( + connectedWalletAddress && + creator.earlyAccessWhitelist?.some( + address => + address.toLowerCase() === connectedWalletAddress.toLowerCase() + ) + ); + const buyDisabledReason = !connectedWalletAddress + ? 'Connect your wallet to buy this key.' + : isEarlyAccessRestricted && !isWhitelisted + ? 'This key is in early access. Your wallet is not on the whitelist.' + : undefined; return (
@@ -203,7 +233,6 @@ function CreatorDetailPageContent() { parentHref="/" currentLabel={`${creator.title} Profile`} /> -
{ - if (window.history.length > 1 && location.key !== 'default') { + if ( + window.history.length > 1 && + location.key !== 'default' + ) { navigate(-1); return; } @@ -230,17 +262,89 @@ function CreatorDetailPageContent() { className="mt-3 shrink-0" />
- {/* 4 Stat Cards */}
- {/* Buy Cooldown Countdown (only meaningful for authenticated users) */} {userAddress && ( )} - +
+
+ {creator.earlyAccessEnabled && ( + + Early access + + )} + {creator.publicLaunchDate && hasValidPublicLaunchDate && ( +

+ {isPublicLaunchPending + ? 'Public launch: ' + : 'Public trading is open. '} + {isPublicLaunchPending && ( + + )} +

+ )} +
+ + + + + +
+ {buyDisabledReason && ( +

+ {buyDisabledReason} +

+ )} + {connectedWalletAddress && ( + { + await tradeMutation.mutateAsync({ + creatorId: creator.id, + amount, + priceStroops: resolveCreatorKeyPriceStroops(creator), + price: creator.price, + maxPriceStroops: slippage?.maxPriceStroops ?? null, + }); + setTradeDialogOpen(false); + }} + /> + )} {/* Share to X Button (only visible for authenticated holders) */}
- {isTwapLoading ? ( -
-
+
+
+ + +
) : twapPrice != null ? ( -
+
- TWAP (24h) + + TWAP (24h) + - +
-
{formatDisplayKeyPrice(twapPrice)}
+
+ {formatDisplayKeyPrice(twapPrice)} +
- {twapDelta != null && {twapDelta < 0 ? '▼' : '▲'} {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot} + {twapDelta != null && ( + + {twapDelta < 0 ? '▼' : '▲'}{' '} + {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot + + )}
) : null} - {/* Staking Rewards */} - {/* Price Chart */}
- {/* Buy Simulation Tool */} - {/* Holder Concentration */}
- {/* Fee Structure */}
@@ -328,15 +464,14 @@ function CreatorDetailPageContent() {
- - {/* Co-Creator Section */} - + {/* Co-Creator Section */}{' '} + {/* Activity Feed */}

diff --git a/src/pages/__tests__/CreatorDetailPage.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx index b5e7850d..0f23eb4c 100644 --- a/src/pages/__tests__/CreatorDetailPage.integration.test.tsx +++ b/src/pages/__tests__/CreatorDetailPage.integration.test.tsx @@ -1,21 +1,28 @@ import type { ComponentProps, ReactNode } from 'react'; -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, Routes, Route } from 'react-router'; import CreatorDetailPage from '@/pages/CreatorDetailPage'; +import CreatorDashboardPage from '@/pages/CreatorDashboardPage'; import { courseService } from '@/services/course.service'; import { ApiError } from '@/services/api.service'; import { queryKeys } from '@/lib/queryKeys'; +import { useAccount } from 'wagmi'; vi.mock('@/services/course.service', () => ({ courseService: { getCourse: vi.fn(), getHoldersPage: vi.fn(), + updateCourse: vi.fn(), }, })); +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), +})); + vi.mock('framer-motion', async () => { const React = await import('react'); type MotionProps = ComponentProps<'div'> & { @@ -45,6 +52,8 @@ vi.mock('framer-motion', async () => { const mockGetCourse = vi.mocked(courseService.getCourse); const mockGetHoldersPage = vi.mocked(courseService.getHoldersPage); +const mockUpdateCourse = vi.mocked(courseService.updateCourse); +const mockUseAccount = vi.mocked(useAccount); function makeFreshQueryClient() { return new QueryClient({ @@ -71,6 +80,10 @@ describe('CreatorDetailPage Integration', () => { queryClient = makeFreshQueryClient(); mockGetCourse.mockReset(); mockGetHoldersPage.mockReset(); + mockUpdateCourse.mockReset(); + mockUseAccount.mockReturnValue({ address: undefined } as ReturnType< + typeof useAccount + >); mockGetHoldersPage.mockResolvedValue({ holders: [], nextCursor: null, @@ -336,4 +349,134 @@ describe('CreatorDetailPage Integration', () => { screen.queryByText(/this creator page could not load/i) ).not.toBeInTheDocument(); }); + + it('shows early access only when enabled and gates buys by wallet whitelist', async () => { + const walletAddress = + 'GABCDE1234567890ABCDE1234567890ABCDE1234567890ABCDEF'; + mockUseAccount.mockReturnValue({ address: walletAddress } as ReturnType< + typeof useAccount + >); + const creator = { + id: 'creator-123', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER' as const, + earlyAccessEnabled: true, + earlyAccessWhitelist: [ + 'GOTHER1234567890ABCDE1234567890ABCDE1234567890ABCDEF', + ], + }; + mockGetCourse.mockResolvedValue(creator); + + render( + + + + } /> + + + + ); + + expect( + await screen.findByTestId('early-access-badge') + ).toBeInTheDocument(); + const buyButton = screen.getByTestId('creator-buy-button'); + expect(buyButton).toBeDisabled(); + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'Your wallet is not on the whitelist' + ); + + act(() => { + queryClient.setQueryData(queryKeys.creators.detail('creator-123'), { + ...creator, + earlyAccessWhitelist: [walletAddress.toLowerCase()], + }); + }); + await waitFor(() => expect(buyButton).toBeEnabled()); + }); + + it('shows the configured public launch date and removes early access badge when disabled', async () => { + mockGetCourse.mockResolvedValue({ + id: 'creator-123', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER', + earlyAccessEnabled: false, + publicLaunchDate: '2030-01-01T12:00:00.000Z', + }); + + render( + + + + } /> + + + + ); + + expect(await screen.findByText(/public launch:/i)).toBeInTheDocument(); + expect( + screen.queryByTestId('early-access-badge') + ).not.toBeInTheDocument(); + }); + + it('persists an early access mode change from the creator settings panel', async () => { + const user = userEvent.setup(); + const creator = { + id: 'creator-123', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.05, + priceStroops: 500_000, + creatorShareSupply: 100, + instructorId: 'arivers', + category: 'Art', + level: 'BEGINNER' as const, + earlyAccessEnabled: false, + }; + mockGetCourse.mockResolvedValue(creator); + mockUpdateCourse.mockResolvedValue({ + ...creator, + earlyAccessEnabled: true, + }); + + render( + + + + } + /> + + + + ); + + const toggle = await screen.findByRole('checkbox', { + name: 'Enable early access', + }); + await user.click(toggle); + + expect(mockUpdateCourse).toHaveBeenCalledWith('creator-123', { + earlyAccessEnabled: true, + }); + expect( + await screen.findByText(/only whitelisted wallets can buy/i) + ).toBeInTheDocument(); + }); }); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 36af6b97..73aeb761 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -86,6 +86,12 @@ export interface Course { deprecated?: boolean; /** Optional human-readable reason surfaced in the deprecation notice. */ deprecationReason?: string | null; + /** Whether purchases are restricted to the early-access whitelist. */ + earlyAccessEnabled?: boolean; + /** Wallet addresses allowed to buy while early access is active. */ + earlyAccessWhitelist?: string[]; + /** ISO timestamp for when public purchases begin. */ + publicLaunchDate?: string | null; } export type CourseSortOption = @@ -364,10 +370,9 @@ class CourseService extends BaseApiService { quantity: number ): Promise> { try { - const response = await this.api.get>>( - `/keys/${keyId}/simulate`, - { params: { quantity } } - ); + const response = await this.api.get< + APIResponse> + >(`/keys/${keyId}/simulate`, { params: { quantity } }); return response.data.data; } catch (error) { throw this.handleError(error); From 35fff08d117ab946a5ee62fb4bb21c427c09c5df Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Wed, 30 Sep 2026 18:56:54 +0100 Subject: [PATCH 2/2] fix(build): restore fee service typing and align component APIs --- src/components/common/TradeDialog.tsx | 514 +++++++++++++++----- src/components/home/TrendingCreatorCard.tsx | 11 +- src/pages/CreatorDetailPage.tsx | 176 +++---- src/services/course.service.ts | 39 +- 4 files changed, 487 insertions(+), 253 deletions(-) diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index c9ce716e..991cf639 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -240,7 +240,8 @@ const TradeDialog: React.FC = ({ // On open: reset state, kick off allowance check + cap fetch. useEffect(() => { if (open) { - triggerElementRef.current = document.activeElement as HTMLElement | null; + triggerElementRef.current = + document.activeElement as HTMLElement | null; setAmountText('1'); setTouched(false); setAdjustmentNote(null); @@ -274,10 +275,18 @@ const TradeDialog: React.FC = ({ } if (event.ctrlKey || event.metaKey || event.altKey) return; const key = event.key; - const presets: Record = { '1': 1, '2': 2, '3': 3, '4': 5, '5': 10 }; + const presets: Record = { + '1': 1, + '2': 2, + '3': 3, + '4': 5, + '5': 10, + }; if (!event.shiftKey && presets[key] !== undefined) { event.preventDefault(); - setAmountText(clampBuyQuantity(presets[key].toString()).value.toString()); + setAmountText( + clampBuyQuantity(presets[key].toString()).value.toString() + ); setTouched(true); return; } @@ -312,13 +321,21 @@ const TradeDialog: React.FC = ({ maxBuyable !== null ? Math.min(BUY_QUANTITY_BOUNDS.MAX_QTY, maxBuyable) : BUY_QUANTITY_BOUNDS.MAX_QTY; - const res = clampBuyQuantity(amountText.trim(), BUY_QUANTITY_BOUNDS.MIN_QTY, maxClamp); + const res = clampBuyQuantity( + amountText.trim(), + BUY_QUANTITY_BOUNDS.MIN_QTY, + maxClamp + ); if (res.adjusted) { setAmountText(res.value.toString()); if (res.reason === 'below_min') { - setAdjustmentNote(`Quantity adjusted to the minimum of ${res.value}.`); + setAdjustmentNote( + `Quantity adjusted to the minimum of ${res.value}.` + ); } else if (res.reason === 'above_max') { - setAdjustmentNote(`Quantity adjusted to the maximum of ${res.value}.`); + setAdjustmentNote( + `Quantity adjusted to the maximum of ${res.value}.` + ); } else { setAdjustmentNote(`Quantity rounded to ${res.value}.`); } @@ -336,14 +353,20 @@ const TradeDialog: React.FC = ({ const validationError = useMemo((): string | null => { const normalized = amountText.trim(); if (!normalized) return 'Please enter an amount.'; - if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.'; + if (!Number.isFinite(parsedAmount)) + return 'Amount must be a valid number.'; if (parsedAmount <= 0) return 'Amount must be greater than zero.'; - if (isPaused) return 'Trading is currently suspended because the contract is paused.'; + if (isPaused) + return 'Trading is currently suspended because the contract is paused.'; if (side === 'buy' && atCap) return 'You have reached the maximum holding cap for this creator.'; if (side === 'buy' && maxBuyable !== null && parsedAmount > maxBuyable) return `You can only buy up to ${formatNumber(maxBuyable)} more key${maxBuyable !== 1 ? 's' : ''} (holding cap).`; - if (side === 'buy' && maxBuyQuantity != null && parsedAmount > maxBuyQuantity) + if ( + side === 'buy' && + maxBuyQuantity != null && + parsedAmount > maxBuyQuantity + ) return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`; if ( (side === 'sell' || side === 'stake' || side === 'transfer') && @@ -351,14 +374,32 @@ const TradeDialog: React.FC = ({ ) return `You can't ${side} more than your holdings (${formatNumber(availableHoldings)} keys).`; return null; - }, [amountText, parsedAmount, side, availableHoldings, atCap, maxBuyable, maxBuyQuantity, isPaused]); + }, [ + amountText, + parsedAmount, + side, + availableHoldings, + atCap, + maxBuyable, + maxBuyQuantity, + isPaused, + ]); const amountValid = validationError === null; const showError = touched && validationError !== null; const priceImpactPercent = useMemo(() => { - if (!amountValid || !Number.isFinite(parsedAmount) || (side !== 'buy' && side !== 'sell')) return 0; - return calculateTradePriceImpact({ side, quantity: parsedAmount, currentSupply: currentSupply ?? 0 }); + if ( + !amountValid || + !Number.isFinite(parsedAmount) || + (side !== 'buy' && side !== 'sell') + ) + return 0; + return calculateTradePriceImpact({ + side, + quantity: parsedAmount, + currentSupply: currentSupply ?? 0, + }); }, [amountValid, parsedAmount, side, currentSupply]); const effectiveCircuitBreakerThresholdPercent = @@ -389,7 +430,8 @@ const TradeDialog: React.FC = ({ side === 'buy' && circuitBreakerStatus?.isBreached ); - const isAllowanceChecking = side === 'stake' && allowanceStatus === 'checking'; + const isAllowanceChecking = + side === 'stake' && allowanceStatus === 'checking'; const requiresAllowanceApproval = side === 'stake' && needsApproval; const confirmDisabled = !amountValid || @@ -401,13 +443,17 @@ const TradeDialog: React.FC = ({ isPaused || requiresAllowanceApproval; - const displayCreatorName = normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; + const displayCreatorName = + normalizeCreatorDisplayName(creatorName) || 'Unnamed creator'; const title = - side === 'buy' ? 'Buy keys' - : side === 'sell' ? 'Sell keys' - : side === 'stake' ? 'Stake keys' - : 'Transfer keys'; + side === 'buy' + ? 'Buy keys' + : side === 'sell' + ? 'Sell keys' + : side === 'stake' + ? 'Stake keys' + : 'Transfer keys'; const confirmLabel = side === 'buy' @@ -443,45 +489,92 @@ const TradeDialog: React.FC = ({ // Network fee estimation effect. useEffect(() => { - if (!open) { setNetworkFeeEstimate({ status: 'idle', fee: null }); return; } + if (!open) { + setNetworkFeeEstimate({ status: 'idle', fee: null }); + return; + } if (!amountValid || !networkFeeEstimateProvider) { - setNetworkFeeEstimate({ status: 'error', fee: null }); return; + setNetworkFeeEstimate({ status: 'error', fee: null }); + return; } let cancelled = false; setNetworkFeeEstimate({ status: 'loading', fee: null }); const feeSide = side === 'buy' || side === 'sell' ? side : 'buy'; - fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { side: feeSide, amount: parsedAmount }) + fetchTradeNetworkFeeEstimate(networkFeeEstimateProvider, { + side: feeSide, + amount: parsedAmount, + }) .then(fee => { if (cancelled) return; - setNetworkFeeEstimate(fee == null ? { status: 'error', fee: null } : { status: 'success', fee }); + setNetworkFeeEstimate( + fee == null + ? { status: 'error', fee: null } + : { status: 'success', fee } + ); }) - .catch(() => { if (!cancelled) setNetworkFeeEstimate({ status: 'error', fee: null }); }); - return () => { cancelled = true; }; + .catch(() => { + if (!cancelled) + setNetworkFeeEstimate({ status: 'error', fee: null }); + }); + return () => { + cancelled = true; + }; }, [amountValid, networkFeeEstimateProvider, open, parsedAmount, side]); const estimatedProceedsStroops = useMemo(() => { - if (side !== 'sell' || !Number.isFinite(parsedAmount) || parsedAmount <= 0) return null; + if ( + side !== 'sell' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 + ) + return null; return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); }, [side, keyPriceStroops, currentSupply, parsedAmount]); const launchPenalty = useMemo( - () => calculateLaunchPenalty(estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps), - [estimatedProceedsStroops, createdAtLedger, currentLedger, launchPenaltyBps] + () => + calculateLaunchPenalty( + estimatedProceedsStroops, + createdAtLedger, + currentLedger, + launchPenaltyBps + ), + [ + estimatedProceedsStroops, + createdAtLedger, + currentLedger, + launchPenaltyBps, + ] ); const estimatedTotalStroops = useMemo(() => { - if (side !== 'buy' || !Number.isFinite(parsedAmount) || parsedAmount <= 0 || keyPriceStroops == null) return null; + if ( + side !== 'buy' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 || + keyPriceStroops == null + ) + return null; return keyPriceStroops * parsedAmount; }, [side, keyPriceStroops, parsedAmount]); const slippageReferencePriceStroops = useMemo(() => { - if (side === 'buy') return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; + if (side === 'buy') + return pricePreview?.totalCostStroops ?? estimatedTotalStroops ?? null; return estimatedProceedsStroops; }, [side, pricePreview, estimatedTotalStroops, estimatedProceedsStroops]); const slippageBounds = useMemo(() => { - if (slippageReferencePriceStroops == null || (side !== 'buy' && side !== 'sell')) return null; - return computeSlippageBounds(side, slippageReferencePriceStroops, slippageTolerancePercent); + if ( + slippageReferencePriceStroops == null || + (side !== 'buy' && side !== 'sell') + ) + return null; + return computeSlippageBounds( + side, + slippageReferencePriceStroops, + slippageTolerancePercent + ); }, [side, slippageReferencePriceStroops, slippageTolerancePercent]); const handleMaxClick = () => { @@ -489,7 +582,10 @@ const TradeDialog: React.FC = ({ if (side === 'sell') { setAmountText(String(Math.max(0, availableHoldings))); } else { - const maxVal = maxBuyQuantity != null ? maxBuyQuantity : BUY_QUANTITY_BOUNDS.MAX_QTY; + const maxVal = + maxBuyQuantity != null + ? maxBuyQuantity + : BUY_QUANTITY_BOUNDS.MAX_QTY; setAmountText(String(maxVal)); } }; @@ -497,52 +593,99 @@ const TradeDialog: React.FC = ({ // Price preview fetch (buy only). useEffect(() => { if (side !== 'buy' || keyPriceStroops == null) { - setPricePreview(null); setPreviewLoading(false); return; + setPricePreview(null); + setPreviewLoading(false); + return; } if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { - setPricePreview(null); setPreviewLoading(false); setPreviewError(null); return; + setPricePreview(null); + setPreviewLoading(false); + setPreviewError(null); + return; } const timeoutId = window.setTimeout(async () => { - if (previewAbortControllerRef.current) previewAbortControllerRef.current.abort(); + if (previewAbortControllerRef.current) + previewAbortControllerRef.current.abort(); setPreviewLoading(true); setPreviewError(null); try { const preview = await fetchPricePreview({ - quantity: parsedAmount, keyPriceStroops, currentSupply: currentSupply ?? 0, protocolFeeBps, creatorFeeBps, + quantity: parsedAmount, + keyPriceStroops, + currentSupply: currentSupply ?? 0, + protocolFeeBps, + creatorFeeBps, }); setPricePreview(preview); } catch (error) { if (error instanceof Error && error.name === 'AbortError') return; - setPreviewError(error instanceof Error ? error.message : 'Failed to fetch price preview'); + setPreviewError( + error instanceof Error + ? error.message + : 'Failed to fetch price preview' + ); setPricePreview(null); } finally { setPreviewLoading(false); } }, 200); return () => clearTimeout(timeoutId); - }, [side, parsedAmount, keyPriceStroops, currentSupply, protocolFeeBps, creatorFeeBps]); + }, [ + side, + parsedAmount, + keyPriceStroops, + currentSupply, + protocolFeeBps, + creatorFeeBps, + ]); // Price preview failure logging. useEffect(() => { - if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; + if ( + process.env.NODE_ENV === 'test' || + !open || + pricePreviewFailureLogged.current + ) + return; if (side === 'buy' && keyPriceStroops == null) { - console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'buy', reason: 'key_price_missing', timestamp: new Date().toISOString() }); + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'buy', + reason: 'key_price_missing', + timestamp: new Date().toISOString(), + }); pricePreviewFailureLogged.current = true; } }, [open, side, keyPriceStroops, creatorName, parsedAmount]); useEffect(() => { - if (process.env.NODE_ENV === 'test' || !open || pricePreviewFailureLogged.current) return; + if ( + process.env.NODE_ENV === 'test' || + !open || + pricePreviewFailureLogged.current + ) + return; if (side === 'sell' && estimatedProceedsStroops == null) { - console.debug('[price-preview-failure]', { creator_name: creatorName, quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, side: 'sell', reason: 'estimate_unavailable', timestamp: new Date().toISOString() }); + console.debug('[price-preview-failure]', { + creator_name: creatorName, + quantity: Number.isFinite(parsedAmount) ? parsedAmount : null, + side: 'sell', + reason: 'estimate_unavailable', + timestamp: new Date().toISOString(), + }); pricePreviewFailureLogged.current = true; } }, [open, side, estimatedProceedsStroops, creatorName, parsedAmount]); const handleConfirm = async () => { if (confirmDisabled) return; - if (requireConfirmation) { setConfirmationOpen(true); return; } + if (requireConfirmation) { + setConfirmationOpen(true); + return; + } await onConfirm(parsedAmount, pricePreview, slippageBounds); - if (side === 'buy' && creatorId) incrementHolding(creatorId, parsedAmount); + if (side === 'buy' && creatorId) + incrementHolding(creatorId, parsedAmount); }; const isMobile = useIsMobile(); @@ -553,19 +696,21 @@ const TradeDialog: React.FC = ({ // the confirmation screen quotes the rate the contract will actually // charge. The breakdown is derived from the live rates and the current // trade notional, so it refreshes whenever the amount changes. - const [dynamicFeeRates, setDynamicFeeRates] = useState( - { + const [dynamicFeeRates, setDynamicFeeRates] = + useState({ protocolFeeBps, creatorRoyaltyBps: creatorFeeBps, - } - ); + }); const [isFeeRateLoading, setFeeRateLoading] = useState(false); const [feeRateError, setFeeRateError] = useState(null); const feeRateAbortRef = useRef(null); useEffect(() => { if (!open) { - setDynamicFeeRates({ protocolFeeBps, creatorRoyaltyBps: creatorFeeBps }); + setDynamicFeeRates({ + protocolFeeBps, + creatorRoyaltyBps: creatorFeeBps, + }); setFeeRateLoading(false); setFeeRateError(null); return; @@ -581,7 +726,9 @@ const TradeDialog: React.FC = ({ setFeeRateError(null); courseService - .getDynamicFeeRate(creatorName, { signal: controller.signal }) + .getDynamicFeeRate(creatorId ?? creatorName, { + signal: controller.signal, + }) .then(rates => { if (controller.signal.aborted) return; // Merge over the dialog's configured rates so a partial @@ -594,14 +741,17 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }) - .catch(error => { + .catch((error: unknown) => { if ( controller.signal.aborted || (error instanceof Error && error.name === 'CanceledError') ) { return; } - if (error instanceof DOMException && error.name === 'AbortError') { + if ( + error instanceof DOMException && + error.name === 'AbortError' + ) { return; } // Keep the last known good rates; just surface the error. @@ -615,13 +765,13 @@ const TradeDialog: React.FC = ({ }, 200); return () => clearTimeout(timeoutId); - }, [open, creatorName, protocolFeeBps, creatorFeeBps]); + }, [open, creatorId, creatorName, protocolFeeBps, creatorFeeBps]); const handleFeeRateRetry = useCallback(() => { setFeeRateError(null); setFeeRateLoading(true); courseService - .getDynamicFeeRate(creatorName) + .getDynamicFeeRate(creatorId ?? creatorName) .then(rates => { setDynamicFeeRates( mergeFeeRates( @@ -631,7 +781,7 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }) - .catch(error => { + .catch((error: unknown) => { setFeeRateError( error instanceof Error ? error.message @@ -639,7 +789,7 @@ const TradeDialog: React.FC = ({ ); setFeeRateLoading(false); }); - }, [creatorName, protocolFeeBps, creatorFeeBps]); + }, [creatorId, creatorName, protocolFeeBps, creatorFeeBps]); const dynamicFeeBreakdown = useMemo(() => { if (side === 'buy') { @@ -688,11 +838,17 @@ const TradeDialog: React.FC = ({ data-testid="trade-dialog-paused-alert" className="flex items-start gap-2.5 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200" > -

@@ -717,7 +873,10 @@ const TradeDialog: React.FC = ({ /> {side === 'sell' && ( - + )} {/* Holding cap warning (#961) — buy side only */} @@ -741,10 +900,12 @@ const TradeDialog: React.FC = ({ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 pr-16 text-white outline-none transition-colors', 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15', showError ? 'border-red-500/60' : '', - (atCap || isPaused) ? 'opacity-40 cursor-not-allowed' : '' + atCap || isPaused ? 'opacity-40 cursor-not-allowed' : '' )} aria-label="Trade amount" - aria-describedby={showError ? 'trade-amount-error' : undefined} + aria-describedby={ + showError ? 'trade-amount-error' : undefined + } aria-invalid={showError || undefined} data-focus-order="1" data-testid="trade-dialog-amount" @@ -753,7 +914,9 @@ const TradeDialog: React.FC = ({ type="button" data-testid="trade-dialog-max-button" onClick={handleMaxClick} - disabled={isSubmitting || (side === 'sell' && availableHoldings <= 0)} + disabled={ + isSubmitting || (side === 'sell' && availableHoldings <= 0) + } className="absolute right-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-0.5 text-xs font-bold text-amber-300 transition-colors hover:bg-amber-400/20 active:scale-95 disabled:pointer-events-none disabled:opacity-40" > MAX @@ -761,13 +924,21 @@ const TradeDialog: React.FC = ({
{side === 'buy' && adjustmentNote && ( -
+
{adjustmentNote}
)} {showError && ( - )} @@ -782,7 +953,9 @@ const TradeDialog: React.FC = ({ )}
- + Holdings: {formatNumber(availableHoldings)} keys {(side === 'sell' || side === 'stake' || side === 'transfer') && @@ -792,7 +965,11 @@ const TradeDialog: React.FC = ({ availableHoldings ? 'negative' : 'neutral'} + tone={ + parsedAmount > availableHoldings + ? 'negative' + : 'neutral' + } /> )}
@@ -806,14 +983,22 @@ const TradeDialog: React.FC = ({ /> )} - + {side === 'buy' && amountValid && ( { setPreviewError(null); setPreviewLoading(true); }} + onRetry={() => { + setPreviewError(null); + setPreviewLoading(true); + }} /> )} {side === 'buy' && estimatedTotalStroops != null && ( @@ -825,7 +1010,10 @@ const TradeDialog: React.FC = ({
)} {side === 'sell' && ( - + )} {amountValid && ( @@ -836,17 +1024,38 @@ const TradeDialog: React.FC = ({ disabled={isSubmitting} /> {slippageBounds && ( -

+

{side === 'buy' ? slippageBounds.maxPriceStroops != null && ( - <>Max price: {formatDisplayKeyPrice(slippageBounds.maxPriceStroops)} - ) + <> + Max price:{' '} + + {formatDisplayKeyPrice( + slippageBounds.maxPriceStroops + )} + + + ) : slippageBounds.minPriceStroops != null && ( - <>Min price: {formatDisplayKeyPrice(slippageBounds.minPriceStroops)} - )} + <> + Min price:{' '} + + {formatDisplayKeyPrice( + slippageBounds.minPriceStroops + )} + + + )}

)} - +
)}
@@ -879,66 +1088,91 @@ const TradeDialog: React.FC = ({ onClick={handleConfirm} disabled={confirmDisabled} aria-busy={isSubmitting || undefined} - title={isPaused ? 'Trading suspended: contract is paused' : undefined} + title={ + isPaused ? 'Trading suspended: contract is paused' : undefined + } data-focus-order="3" data-testid="trade-dialog-confirm" > - + {confirmLabel} ); - const confirmationModal = (side === 'buy' || side === 'sell') ? ( - { - if (isCircuitBreakerBreached) return; - await onConfirm(parsedAmount, pricePreview, slippageBounds); - if (side === 'buy' && creatorId) incrementHolding(creatorId, parsedAmount); - setConfirmationOpen(false); - }} - onCancel={() => setConfirmationOpen(false)} - isSubmitting={isSubmitting} - /> - ) : null; + const confirmationModal = + side === 'buy' || side === 'sell' ? ( + { + if (isCircuitBreakerBreached) return; + await onConfirm(parsedAmount, pricePreview, slippageBounds); + if (side === 'buy' && creatorId) + incrementHolding(creatorId, parsedAmount); + setConfirmationOpen(false); + }} + onCancel={() => setConfirmationOpen(false)} + isSubmitting={isSubmitting} + /> + ) : null; if (isMobile) { return ( <> - !isBusy && onOpenChange(next)}> + !isBusy && onOpenChange(next)} + > { event.preventDefault(); amountInputRef.current?.focus(); }} - onCloseAutoFocus={event => { event.preventDefault(); triggerElementRef.current?.focus(); }} - onEscapeKeyDown={event => { if (isBusy) event.preventDefault(); }} - onInteractOutside={event => { if (isBusy) event.preventDefault(); }} + onOpenAutoFocus={event => { + event.preventDefault(); + amountInputRef.current?.focus(); + }} + onCloseAutoFocus={event => { + event.preventDefault(); + triggerElementRef.current?.focus(); + }} + onEscapeKeyDown={event => { + if (isBusy) event.preventDefault(); + }} + onInteractOutside={event => { + if (isBusy) event.preventDefault(); + }} >
- {title} + + {title} + - {side === 'buy' ? `Purchase creator keys for ${displayCreatorName}.` - : side === 'sell' ? `Sell creator keys for ${displayCreatorName}.` - : side === 'stake' ? `Stake creator keys for ${displayCreatorName}.` - : `Transfer creator keys for ${displayCreatorName}.`} + {side === 'buy' + ? `Purchase creator keys for ${displayCreatorName}.` + : side === 'sell' + ? `Sell creator keys for ${displayCreatorName}.` + : side === 'stake' + ? `Stake creator keys for ${displayCreatorName}.` + : `Transfer creator keys for ${displayCreatorName}.`}
{bodyContent} @@ -954,23 +1188,39 @@ const TradeDialog: React.FC = ({ return ( <> - !isBusy && onOpenChange(next)}> + !isBusy && onOpenChange(next)} + > { event.preventDefault(); amountInputRef.current?.focus(); }} - onCloseAutoFocus={event => { event.preventDefault(); triggerElementRef.current?.focus(); }} - onEscapeKeyDown={event => { if (isBusy) event.preventDefault(); }} - onInteractOutside={event => { if (isBusy) event.preventDefault(); }} + onOpenAutoFocus={event => { + event.preventDefault(); + amountInputRef.current?.focus(); + }} + onCloseAutoFocus={event => { + event.preventDefault(); + triggerElementRef.current?.focus(); + }} + onEscapeKeyDown={event => { + if (isBusy) event.preventDefault(); + }} + onInteractOutside={event => { + if (isBusy) event.preventDefault(); + }} > {title} - {side === 'buy' ? `Purchase creator keys for ${displayCreatorName}.` - : side === 'sell' ? `Sell creator keys for ${displayCreatorName}.` - : side === 'stake' ? `Stake creator keys for ${displayCreatorName}.` - : `Transfer creator keys for ${displayCreatorName}.`} + {side === 'buy' + ? `Purchase creator keys for ${displayCreatorName}.` + : side === 'sell' + ? `Sell creator keys for ${displayCreatorName}.` + : side === 'stake' + ? `Stake creator keys for ${displayCreatorName}.` + : `Transfer creator keys for ${displayCreatorName}.`} @@ -995,16 +1245,24 @@ const TradeDialog: React.FC = ({ data-testid="trade-dialog-shortcut-hint" > - Enter + + Enter + confirm - Esc + + Esc + close - + - - + + + + + + - + adjust diff --git a/src/components/home/TrendingCreatorCard.tsx b/src/components/home/TrendingCreatorCard.tsx index f61f7805..bf3f39c0 100644 --- a/src/components/home/TrendingCreatorCard.tsx +++ b/src/components/home/TrendingCreatorCard.tsx @@ -25,7 +25,6 @@ export default function TrendingCreatorCard({ creator }: Props) { className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]" style={{ imageRendering: 'pixelated' }} /> - {/* Body */} @@ -35,7 +34,7 @@ export default function TrendingCreatorCard({ creator }: Props) { {/* Deprecation badge (issue #996): a deprecated key must be marked in marketplace listings so buyers see its status before they click through. */} - {creator.deprecation && ( + {creator.deprecated && (
- + {priceXlm} XLM @@ -83,6 +86,6 @@ export default function TrendingCreatorCard({ creator }: Props) {
- + ); } diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 27e26d0c..c85d8edf 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -10,7 +10,7 @@ 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 BondingCurveChart from '@/components/common/BondingCurveChart'; import KeySimulationTool from '@/components/common/KeySimulationTool'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; @@ -207,27 +207,24 @@ function CreatorDetailPageContent() { */ const isWhitelistGateActive = Boolean( creator?.isWhitelistEnabled ?? - creator?.whitelistEnabled ?? - creator?.earlyAccessEnabled ?? - false + creator?.whitelistEnabled ?? + creator?.earlyAccessEnabled ?? + false ); const whitelistEntries = creator?.whitelist ?? []; const isUserWhitelisted = Boolean( userAddress && - ( - whitelistEntries.some( - entry => - entry.walletAddress?.toUpperCase() === - userAddress.toUpperCase() - ) || - (creator?.earlyAccessWhitelist ?? []).some( - address => address.toLowerCase() === userAddress.toLowerCase() - ) || - (creator?.instructorId && - creator.instructorId.toUpperCase() === userAddress.toUpperCase()) - ) + (whitelistEntries.some( + entry => + entry.walletAddress?.toUpperCase() === userAddress.toUpperCase() + ) || + (creator?.earlyAccessWhitelist ?? []).some( + address => address.toLowerCase() === userAddress.toLowerCase() + ) || + (creator?.instructorId && + creator.instructorId.toUpperCase() === userAddress.toUpperCase())) ); const publicLaunchTimestamp = creator?.publicLaunchDate @@ -235,8 +232,7 @@ function CreatorDetailPageContent() { : null; const hasValidPublicLaunchDate = - publicLaunchTimestamp != null && - Number.isFinite(publicLaunchTimestamp); + publicLaunchTimestamp != null && Number.isFinite(publicLaunchTimestamp); const isPublicLaunchPending = hasValidPublicLaunchDate && publicLaunchTimestamp > Date.now(); @@ -259,8 +255,11 @@ function CreatorDetailPageContent() { /* * Track stale data indicator. */ - const { shouldShowBadge, handleRefetch } = - useCreatorProfileStaleIndicator(id || '', isFetching, () => refetch()); + const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator( + id || '', + isFetching, + () => refetch() + ); /* * Trade mutation. @@ -275,11 +274,10 @@ function CreatorDetailPageContent() { */ const { data: tradeCooldownStatus } = useTradeCooldownStatus(id || ''); - const tradeCooldown: ActiveTradeCooldown | null = - resolveActiveTradeCooldown( - tradeCooldownStatus, - nextBuyAllowedAt - ); + const tradeCooldown: ActiveTradeCooldown | null = resolveActiveTradeCooldown( + tradeCooldownStatus, + nextBuyAllowedAt + ); const isTradeCooldownActive = isActiveCooldown(tradeCooldown); @@ -349,10 +347,7 @@ function CreatorDetailPageContent() { We couldn't find a creator with that ID.

- + Back to creators
@@ -381,10 +376,7 @@ function CreatorDetailPageContent() { */ const auctionLeadBid = auctionPhase === 'active' - ? resolveHighestBid( - creator.auctionBids, - creator.auctionHighestBid - ) + ? resolveHighestBid(creator.auctionBids, creator.auctionHighestBid) : null; const auctionStatValue = @@ -397,15 +389,11 @@ function CreatorDetailPageContent() { const statItems = [ { label: - auctionPhase === 'active' - ? 'Current Highest Bid' - : 'Current Price', + auctionPhase === 'active' ? 'Current Highest Bid' : 'Current Price', value: auctionPhase === 'active' ? auctionStatValue - : formatDisplayKeyPrice( - resolveCreatorKeyPriceStroops(creator) - ), + : formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)), }, { label: 'Key Supply', @@ -438,9 +426,7 @@ function CreatorDetailPageContent() { const twapPrice = twap?.priceStroops ?? null; const twapDelta = - twapPrice != null && spotPrice != null - ? twapPrice - spotPrice - : null; + twapPrice != null && spotPrice != null ? twapPrice - spotPrice : null; const hasRealStakingData = creator.stakingPoolBalance != null || @@ -493,16 +479,12 @@ function CreatorDetailPageContent() {
{ if ( @@ -557,9 +539,7 @@ function CreatorDetailPageContent() { {/* Deprecation Notice */} {isKeyDeprecated(creator) && (
- +
)} @@ -608,26 +588,21 @@ function CreatorDetailPageContent() { )} - {creator.publicLaunchDate && - hasValidPublicLaunchDate && ( -

- {isPublicLaunchPending - ? 'Public launch: ' - : 'Public trading is open. '} - - {isPublicLaunchPending && ( - - )} -

- )} + {creator.publicLaunchDate && hasValidPublicLaunchDate && ( +

+ {isPublicLaunchPending + ? 'Public launch: ' + : 'Public trading is open. '} + + {isPublicLaunchPending && ( + + )} +

+ )}
{isKeyDeprecated(creator) ? ( @@ -676,9 +651,7 @@ function CreatorDetailPageContent() { auctionSupply={creator.auctionSupply} auctionSold={creator.auctionSold} auctionEndsAt={creator.auctionEndsAt} - auctionMinIncrement={ - creator.auctionMinIncrement - } + auctionMinIncrement={creator.auctionMinIncrement} auctionHighestBid={creator.auctionHighestBid} auctionBids={creator.auctionBids} /> @@ -686,9 +659,7 @@ function CreatorDetailPageContent() { {/* Buy Cooldown Countdown */} {userAddress && !isTradeCooldownActive && ( - + )} {/* Share to X */} @@ -710,10 +681,7 @@ function CreatorDetailPageContent() { className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price" > -
+
@@ -763,10 +731,7 @@ function CreatorDetailPageContent() { } > {twapDelta < 0 ? '▼' : '▲'}{' '} - {formatDisplayKeyPrice( - Math.abs(twapDelta) - )}{' '} - vs spot + {formatDisplayKeyPrice(Math.abs(twapDelta))} vs spot )}
@@ -774,10 +739,7 @@ function CreatorDetailPageContent() { ) : null} {/* Staking Rewards */} - + {/* Price Curve */} {auctionPhase !== 'active' && ( @@ -792,9 +754,7 @@ function CreatorDetailPageContent() { @@ -802,16 +762,12 @@ function CreatorDetailPageContent() { {/* Graduated Curve Milestones */} {/* Buy Simulation Tool */} @@ -891,13 +847,9 @@ function CreatorDetailPageContent() { >

- 🎉 Welcome to the exclusive content section! - Here you can access premium videos, articles, - and perks from{' '} - {creator.title || - creator.name || - 'this creator'} - . + 🎉 Welcome to the exclusive content section! Here you + can access premium videos, articles, and perks from{' '} + {creator.title || creator.name || 'this creator'}.

@@ -918,11 +870,7 @@ function CreatorDetailPageContent() { open={buybackModalOpen} onOpenChange={setBuybackModalOpen} creatorId={creator.id} - creatorTitle={ - creator.title || - creator.name || - 'Creator Key' - } + creatorTitle={creator.title || creator.name || 'Creator Key'} holdingsCount={holdingsCount} buybackPriceStroops={ resolveCreatorKeyPriceStroops(creator) ?? 0 @@ -939,13 +887,9 @@ function CreatorDetailPageContent() { { + try { + const response = await this.api.get< + APIResponse + >(`/keys/${keyId}/fee-rate`, { signal: config?.signal }); + + return response.data.data; + } catch (error: unknown) { + if ( + error instanceof Error && + (error.name === 'CanceledError' || error.name === 'AbortError') + ) { + throw error; + } + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ERR_CANCELED' + ) { + throw error; + } + + throw this.handleError(error); + } + } + // Get the creator vesting schedule - GET /keys/:keyId/vesting (#960) async getKeyVesting( keyId: string, @@ -839,4 +868,4 @@ class CourseService extends BaseApiService { } } -export const courseService = new CourseService(); \ No newline at end of file +export const courseService = new CourseService();