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)}
/>
-
+
+
+
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 && (
+
+ {new Date(
+ creator.publicLaunchDate
+ ).toLocaleString()}
+
+ )}
+
+ )}
+
+
+
+ setTradeDialogOpen(true)}
+ >
+ Buy key
+
+
+
+
+ {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"
>
-
+
-
Trading suspended — contract paused
+
+ Trading suspended — contract paused
+
- Actions are disabled until the emergency pause is lifted by the admin.
+ Actions are disabled until the emergency pause is lifted by
+ the admin.
@@ -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 && (
-
+
{validationError}
)}
@@ -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 && (
-
- {new Date(
- creator.publicLaunchDate
- ).toLocaleString()}
-
- )}
-
- )}
+ {creator.publicLaunchDate && hasValidPublicLaunchDate && (
+
+ {isPublicLaunchPending
+ ? 'Public launch: '
+ : 'Public trading is open. '}
+
+ {isPublicLaunchPending && (
+
+ {new Date(
+ creator.publicLaunchDate
+ ).toLocaleString()}
+
+ )}
+
+ )}
{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();