Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions .kiro/specs/holder-count-cache-invalidation-test/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ 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
- Import `formatCompactNumber` from `@/utils/numberFormat.utils`
- 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<number | null>` as an injected parameter (avoids module-level `vi.mock` in tests)
- Export `HolderCountResult` interface `{ count: number | null; isLoading: boolean; isError: boolean }`
- 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<number | null>`
- Call `useCreatorHolderCount(creatorId, fetchHolderCount)` and pipe `count` through `getFeaturedCreatorKeyHolderCopy`
Expand All @@ -34,17 +34,17 @@ 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 `<MiniStatChip label="Audience" …>` block (lines ~1199–1205) with `<FeaturedCreatorAudienceChip creatorId={featuredCreator.id} fetchHolderCount={...} />`
- Pass a `fetchHolderCount` implementation that returns `Promise.resolve(FEATURED_CREATOR_KEY_HOLDER_COUNT)` (preserves existing behaviour until the real endpoint lands)
- Remove the now-unused `featuredCreatorKeyHolderCopy` derived variable (line ~560–563) and the inline `getFeaturedCreatorKeyHolderCopy` function definition (lines ~81–100)
- 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`
Expand All @@ -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 `<QueryClientProvider>` + `<MemoryRouter>`
- _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`
Expand All @@ -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)
Expand Down
147 changes: 147 additions & 0 deletions src/components/common/BuyFeeBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -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<BuyFeeBreakdownProps> = ({
breakdown,
isLoading,
error,
onRetry,
}) => {
if (error) {
return (
<div
className="flex items-start gap-3 rounded-lg border border-red-500/30 bg-red-500/5 p-3"
role="alert"
data-testid="buy-fee-breakdown-error"
>
<AlertCircle className="h-4 w-4 flex-shrink-0 text-red-400 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-red-300 mb-2">{error}</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRetry}
className="text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1 h-auto"
data-testid="buy-fee-breakdown-retry"
>
<RotateCcw className="h-3 w-3 mr-1" />
Retry
</Button>
</div>
</div>
);
}

if (isLoading) {
return (
<div
className="space-y-2"
role="status"
aria-live="polite"
data-testid="buy-fee-breakdown-loading"
>
<div className="flex justify-between items-center">
<span className="text-xs text-white/60">Calculating fees…</span>
<span className="h-3 w-16 bg-white/10 rounded animate-pulse" />
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-white/60">Protocol fee</span>
<span className="h-3 w-12 bg-white/10 rounded animate-pulse" />
</div>
</div>
);
}

if (!breakdown) {
return null;
}

const protocolFeePercentage = (breakdown.protocolFeeBps / 100).toFixed(2);
const creatorFeePercentage = (breakdown.creatorFeeBps / 100).toFixed(2);

return (
<div
className="space-y-2 rounded-lg border border-white/10 bg-white/[0.02] p-3"
data-testid="buy-fee-breakdown"
>
{/* Gross cost row */}
<div
className="flex justify-between items-center text-xs"
data-testid="buy-fee-breakdown-gross"
>
<span className="text-white/70">Gross cost</span>
<span className="font-mono text-white/90">
{formatDisplayKeyPrice(breakdown.grossCostStroops)}
</span>
</div>

{/* Protocol fee row */}
{breakdown.protocolFeeBps > 0 && (
<div
className="flex justify-between items-center text-xs"
data-testid="buy-fee-breakdown-protocol"
>
<span className="text-white/70">
Protocol fee ({protocolFeePercentage}%)
</span>
<span className="font-mono text-white/90">
{formatDisplayKeyPrice(breakdown.protocolFeeStroops)}
</span>
</div>
)}

{/* Creator fee row */}
{breakdown.creatorFeeBps > 0 && (
<div
className="flex justify-between items-center text-xs"
data-testid="buy-fee-breakdown-creator"
>
<span className="text-white/70">
Creator fee ({creatorFeePercentage}%)
</span>
<span className="font-mono text-white/90">
{formatDisplayKeyPrice(breakdown.creatorFeeStroops)}
</span>
</div>
)}

{/* Total row */}
<div
className="flex justify-between items-center text-xs pt-2 border-t border-white/10"
data-testid="buy-fee-breakdown-total"
>
<span className="font-semibold text-white">Total cost</span>
<span className="font-mono font-semibold text-amber-300/90">
{formatDisplayKeyPrice(breakdown.totalCostStroops)}
</span>
</div>
</div>
);
};

export default BuyFeeBreakdown;
Loading
Loading