diff --git a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md index 648afeb7..1d39f512 100644 --- a/.kiro/specs/holder-count-cache-invalidation-test/tasks.md +++ b/.kiro/specs/holder-count-cache-invalidation-test/tasks.md @@ -8,7 +8,7 @@ The production diff is intentionally small: one utility file, one hook, one comp ## Tasks -- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module +- [x] 1. Extract `getFeaturedCreatorKeyHolderCopy` to a shared utility module - Create `src/utils/holderCount.utils.ts` - Move the `getFeaturedCreatorKeyHolderCopy` function (currently defined inline in `LandingPage.tsx` at line ~81) into the new file - Export `HolderCountCopy` interface and `getFeaturedCreatorKeyHolderCopy` function @@ -16,7 +16,7 @@ The production diff is intentionally small: one utility file, one hook, one comp - Keep the existing inline definition in `LandingPage.tsx` for now — it will be replaced in Task 4 - _Requirements: 5.1, 5.2, 5.3, 5.4_ -- [x] 2. Create `useCreatorHolderCount` hook +- [x] 2. Create `useCreatorHolderCount` hook - Create `src/hooks/useCreatorHolderCount.ts` - Implement `useQuery` with query key `['creator', creatorId, 'holderCount']` and `staleTime: 30_000` - Accept `fetchHolderCount: (id: string) => Promise` as an injected parameter (avoids module-level `vi.mock` in tests) @@ -24,7 +24,7 @@ The production diff is intentionally small: one utility file, one hook, one comp - Return `{ count: data ?? null, isLoading, isError }` - _Requirements: 2.1, 2.2, 2.3_ -- [x] 3. Create `FeaturedCreatorAudienceChip` component +- [x] 3. Create `FeaturedCreatorAudienceChip` component - Create `src/components/common/FeaturedCreatorAudienceChip.tsx` - Accept props: `creatorId: string` and `fetchHolderCount: (id: string) => Promise` - Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy` @@ -34,7 +34,7 @@ The production diff is intentionally small: one utility file, one hook, one comp - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` - _Requirements: 1.1, 1.3, 1.4, 3.1, 3.2, 5.1, 5.2, 5.3_ -- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip` +- [x] 4. Update `LandingPage.tsx` to use `FeaturedCreatorAudienceChip` - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` - Replace the inline `` block (lines ~1199–1205) with `` - Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands) @@ -42,9 +42,9 @@ The production diff is intentionally small: one utility file, one hook, one comp - Verify `LandingPage.tsx` still compiles and the keyboard test (`LandingPage.keyboard.test.tsx`) still passes - _Requirements: 1.1, 3.4_ -- [-] 5. Write the integration test +- [ ] 5. Write the integration test - Create `src/pages/__tests__/holderCountCacheInvalidation.test.tsx` - - [-] 5.1 Set up test scaffolding + - [ ] 5.1 Set up test scaffolding - Import `QueryClient`, `QueryClientProvider` from `@tanstack/react-query`; `MemoryRouter` from `react-router`; `render`, `screen`, `waitFor`, `act` from `@testing-library/react`; `fc` from `fast-check`; `beforeEach`, `afterEach`, `describe`, `expect`, `it`, `vi` from `vitest` - Import `FeaturedCreatorAudienceChip` from `@/components/common/FeaturedCreatorAudienceChip` - Import `getFeaturedCreatorKeyHolderCopy` from `@/utils/holderCount.utils` @@ -56,28 +56,28 @@ The production diff is intentionally small: one utility file, one hook, one comp - Implement `createWrapper(queryClient)` returning a component that wraps children in `` + `` - _Requirements: 4.1, 4.2, 4.3, 4.4_ - - [~] 5.2 Write property test for Property 1 — initial render round-trip + - [ ] 5.2 Write property test for Property 1 — initial render round-trip - **Property 1: Initial render round-trip** - **Validates: Requirements 1.1, 5.4** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` - For each `count`: create fresh `queryClient`, seed with `queryClient.setQueryData(['creator', CREATOR_ID, 'holderCount'], count)`, render `FeaturedCreatorAudienceChip` with wrapper, assert `screen.getByText(getFeaturedCreatorKeyHolderCopy(count).value)` is in the document, assert `mockFetchHolderCount` was NOT called, then `unmount()` - _Requirements: 1.1, 1.2, 5.4_ - - [~] 5.3 Write property test for Property 2 — stale-while-revalidate display stability + - [ ] 5.3 Write property test for Property 2 — stale-while-revalidate display stability - **Property 2: Stale-while-revalidate display stability** - **Validates: Requirements 2.3** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 1_000_000 }), ...)` with `numRuns: 100` - For each `initialCount`: seed cache, render component, call `queryClient.invalidateQueries` but do NOT resolve the pending `mockFetchHolderCount` (use a `Promise` that never resolves during the assertion window), assert old value is still visible and no blank/error state - _Requirements: 2.3_ - - [~] 5.4 Write property test for Property 3 — post-invalidation update round-trip + - [ ] 5.4 Write property test for Property 3 — post-invalidation update round-trip - **Property 3: Post-invalidation update round-trip** - **Validates: Requirements 3.1, 3.2, 3.4** - Use `fc.asyncProperty(fc.integer({ min: 1, max: 999 }), fc.integer({ min: 1000, max: 1_000_000 }), ...)` with `numRuns: 100` (disjoint ranges guarantee `initialCount !== updatedCount`) - For each pair `(initialCount, updatedCount)`: seed cache with `initialCount`, render, spy on `window.location.reload`, invalidate query, await `waitFor` assertion that updated text is visible and old text is gone, assert `reloadSpy` was NOT called, `unmount()` - _Requirements: 3.1, 3.2, 3.3, 3.4_ - - [~] 5.5 Write property test for Property 4 — format function round-trip + - [ ] 5.5 Write property test for Property 4 — format function round-trip - **Property 4: Format function round-trip** - **Validates: Requirements 5.1, 5.4** - Use synchronous `fc.property(fc.integer({ min: 1, max: 10_000_000 }), ...)` with `numRuns: 200` @@ -91,7 +91,7 @@ The production diff is intentionally small: one utility file, one hook, one comp - After invalidation + resolved refetch: assert `mockFetchHolderCount` was called exactly once with `CREATOR_ID` - _Requirements: 1.3, 1.4, 2.2, 2.4_ -- [~] 6. Checkpoint — run tests and confirm everything passes +- [ ] 6. Checkpoint — run tests and confirm everything passes - Run `pnpm test` (or `pnpm vitest run`) from `accesslayer-client--fork/` - Confirm `holderCountCacheInvalidation.test.tsx` passes all property and edge-case tests - Confirm `LandingPage.keyboard.test.tsx` still passes (no regression from Task 4 changes) diff --git a/src/components/common/BuyFeeBreakdown.tsx b/src/components/common/BuyFeeBreakdown.tsx new file mode 100644 index 00000000..aa1832c9 --- /dev/null +++ b/src/components/common/BuyFeeBreakdown.tsx @@ -0,0 +1,147 @@ +/** + * Buy fee breakdown display component. + * Shows gross cost, protocol fee, creator fee, and total before purchase confirmation. + */ + +import React from 'react'; +import { AlertCircle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import type { FeeBreakdown } from '@/utils/pricePreview.utils'; + +export interface BuyFeeBreakdownProps { + /** Fee breakdown data from price preview */ + breakdown: FeeBreakdown | null; + /** Is the preview currently loading? */ + isLoading: boolean; + /** Error message if preview failed */ + error: string | null; + /** Callback when user clicks retry button */ + onRetry: () => void; +} + +/** + * Displays a detailed fee breakdown for a buy transaction. + * Renders gross cost, protocol fee (%), creator fee (%), and total cost. + * Shows loading and error states with inline retry capability. + */ +const BuyFeeBreakdown: React.FC = ({ + breakdown, + isLoading, + error, + onRetry, +}) => { + if (error) { + return ( +
+ +
+

{error}

+ +
+
+ ); + } + + if (isLoading) { + return ( +
+
+ Calculating fees… + +
+
+ Protocol fee + +
+
+ ); + } + + if (!breakdown) { + return null; + } + + const protocolFeePercentage = (breakdown.protocolFeeBps / 100).toFixed(2); + const creatorFeePercentage = (breakdown.creatorFeeBps / 100).toFixed(2); + + return ( +
+ {/* Gross cost row */} +
+ Gross cost + + {formatDisplayKeyPrice(breakdown.grossCostStroops)} + +
+ + {/* Protocol fee row */} + {breakdown.protocolFeeBps > 0 && ( +
+ + Protocol fee ({protocolFeePercentage}%) + + + {formatDisplayKeyPrice(breakdown.protocolFeeStroops)} + +
+ )} + + {/* Creator fee row */} + {breakdown.creatorFeeBps > 0 && ( +
+ + Creator fee ({creatorFeePercentage}%) + + + {formatDisplayKeyPrice(breakdown.creatorFeeStroops)} + +
+ )} + + {/* Total row */} +
+ Total cost + + {formatDisplayKeyPrice(breakdown.totalCostStroops)} + +
+
+ ); +}; + +export default BuyFeeBreakdown; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 9b0b302e..4dc5bdbf 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -11,12 +11,20 @@ import { } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { formatNumber } from '@/utils/numberFormat.utils'; -import { formatDisplayKeyPrice, estimateSellProceeds } from '@/utils/keyPriceDisplay.utils'; +import { + formatDisplayKeyPrice, + estimateSellProceeds, +} from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; -import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; +import BuyFeeBreakdown from '@/components/common/BuyFeeBreakdown'; +import { TRADE_FEE_ESTIMATE, FEE_BOUNDS } from '@/constants/fees'; import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; import { clampBuyQuantity } from '@/utils/buyQuantity'; +import { + fetchPricePreview, + type FeeBreakdown, +} from '@/utils/pricePreview.utils'; export type TradeSide = 'buy' | 'sell'; @@ -29,8 +37,15 @@ export interface TradeDialogProps { keyPriceStroops?: number | null; /** Current key supply for estimating sell proceeds. */ currentSupply?: number | null; + /** Protocol fee in basis points for fee preview (defaults to FEE_BOUNDS.DEFAULT_FEE_BPS) */ + protocolFeeBps?: number; + /** Creator fee in basis points for fee preview (defaults to FEE_BOUNDS.DEFAULT_FEE_BPS) */ + creatorFeeBps?: number; onOpenChange: (open: boolean) => void; - onConfirm: (amount: number) => Promise | void; + onConfirm: ( + amount: number, + pricePreview?: FeeBreakdown | null + ) => Promise | void; isSubmitting?: boolean; } @@ -41,14 +56,20 @@ const TradeDialog: React.FC = ({ availableHoldings, keyPriceStroops, currentSupply, + protocolFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS, + creatorFeeBps = FEE_BOUNDS.DEFAULT_FEE_BPS, onOpenChange, onConfirm, isSubmitting = false, }) => { const [amountText, setAmountText] = useState('1'); const [touched, setTouched] = useState(false); + const [pricePreview, setPricePreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); const amountInputRef = useRef(null); const pricePreviewFailureLogged = useRef(false); + const previewAbortControllerRef = useRef(null); // TradeDialog is opened via `open`/`onOpenChange` props from several // different external trigger buttons (see LandingPage.tsx), never via // Radix's own . That means Radix's built-in @@ -60,9 +81,13 @@ const TradeDialog: React.FC = ({ useEffect(() => { if (open) { - triggerElementRef.current = document.activeElement as HTMLElement | null; + triggerElementRef.current = + document.activeElement as HTMLElement | null; setAmountText('1'); setTouched(false); + setPricePreview(null); + setPreviewLoading(false); + setPreviewError(null); pricePreviewFailureLogged.current = false; } }, [open]); @@ -87,7 +112,8 @@ 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 (side === 'sell' && parsedAmount > availableHoldings) return `You can't sell more than your holdings (${formatNumber(availableHoldings)} keys).`; @@ -105,20 +131,91 @@ const TradeDialog: React.FC = ({ ); const estimatedProceedsStroops = useMemo(() => { - if (side !== 'sell' || !Number.isFinite(parsedAmount) || parsedAmount <= 0) { + if ( + side !== 'sell' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 + ) { return null; } return estimateSellProceeds(keyPriceStroops, currentSupply, parsedAmount); }, [side, keyPriceStroops, currentSupply, parsedAmount]); const estimatedTotalStroops = useMemo(() => { - if (side !== 'buy' || !Number.isFinite(parsedAmount) || parsedAmount <= 0) { + if ( + side !== 'buy' || + !Number.isFinite(parsedAmount) || + parsedAmount <= 0 + ) { return null; } if (keyPriceStroops == null) return null; return keyPriceStroops * parsedAmount; }, [side, keyPriceStroops, parsedAmount]); + // Fetch price preview (fee breakdown) for buy transactions + useEffect(() => { + // Only fetch for buy transactions + if (side !== 'buy' || keyPriceStroops == null) { + setPricePreview(null); + setPreviewLoading(false); + return; + } + + // Don't fetch if amount is invalid + if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { + setPricePreview(null); + setPreviewLoading(false); + setPreviewError(null); + return; + } + + // Debounce the fetch slightly to avoid too many requests while typing + const timeoutId = window.setTimeout(async () => { + // Cancel previous fetch if one is in progress + if (previewAbortControllerRef.current) { + previewAbortControllerRef.current.abort(); + } + + setPreviewLoading(true); + setPreviewError(null); + + try { + const preview = await fetchPricePreview({ + quantity: parsedAmount, + keyPriceStroops, + currentSupply: currentSupply ?? 0, + protocolFeeBps, + creatorFeeBps, + }); + + setPricePreview(preview); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + // Request was cancelled, ignore + return; + } + setPreviewError( + error instanceof Error + ? error.message + : 'Failed to fetch price preview' + ); + setPricePreview(null); + } finally { + setPreviewLoading(false); + } + }, 200); // Debounce by 200ms + + return () => clearTimeout(timeoutId); + }, [ + side, + parsedAmount, + keyPriceStroops, + currentSupply, + protocolFeeBps, + creatorFeeBps, + ]); + useEffect(() => { if (process.env.NODE_ENV === 'test') return; if (!open || pricePreviewFailureLogged.current) return; @@ -211,7 +308,9 @@ const TradeDialog: React.FC = ({ showError ? 'border-red-500/60' : '' )} 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" @@ -254,6 +353,17 @@ const TradeDialog: React.FC = ({ className="text-white/45" /> )} + {side === 'buy' && amountValid && ( + { + setPreviewError(null); + setPreviewLoading(true); + }} + /> + )} {side === 'buy' && estimatedTotalStroops != null && (
Estimated total (approximate):{' '} @@ -300,8 +410,13 @@ const TradeDialog: React.FC = ({