Skip to content
Merged
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
86 changes: 86 additions & 0 deletions src/components/common/BuyPriceEstimate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import {
computeBuyCost,
DEFAULT_BONDING_CURVE_PARAMS,
type BondingCurveParams,
} from '@/utils/bondingCurve.utils';
import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';

export interface BuyPriceEstimateProps {
/** Current key supply for the creator being priced. */
currentSupply: number;
/** Number of keys the user intends to buy. */
quantity: number;
/** Bonding curve parameters; defaults to the platform-wide curve. */
params?: BondingCurveParams;
/** Called with the quantity when the buy button is pressed. */
onBuy?: (quantity: number) => void;
}

/**
* Debounce before recomputing the bonding-curve price preview after the
* quantity changes. computeBuyCost() itself is synchronous, but debouncing
* avoids recalculating (and re-rendering) on every keystroke while the user
* is still typing a multi-digit quantity.
*/
const PRICE_CALCULATION_DEBOUNCE_MS = 150;

/**
* Shows the total XLM cost to buy `quantity` keys at the current bonding
* curve price, recalculating via computeBuyCost() whenever the quantity (or
* current supply) changes. Renders a buy button only for a positive
* quantity, and 0 XLM with no buy button for a zero quantity.
*/
export default function BuyPriceEstimate({
currentSupply,
quantity,
params = DEFAULT_BONDING_CURVE_PARAMS,
onBuy,
}: BuyPriceEstimateProps) {
const [isCalculating, setIsCalculating] = useState(false);
const [totalCostStroops, setTotalCostStroops] = useState<number>(() =>
quantity > 0 ? computeBuyCost(currentSupply, quantity, params) : 0
);

useEffect(() => {
if (quantity <= 0) {
setIsCalculating(false);
setTotalCostStroops(0);
return;
}

setIsCalculating(true);
const timer = window.setTimeout(() => {
setTotalCostStroops(computeBuyCost(currentSupply, quantity, params));
setIsCalculating(false);
}, PRICE_CALCULATION_DEBOUNCE_MS);

return () => window.clearTimeout(timer);
}, [currentSupply, quantity, params]);

const canBuy = quantity > 0 && !isCalculating;

return (
<div data-testid="buy-price-estimate">
{isCalculating ? (
<span role="status" aria-live="polite" data-testid="buy-price-loading">
Calculating price…
</span>
) : (
<span data-testid="buy-price-total">
{formatDisplayKeyPrice(totalCostStroops)}
</span>
)}
{quantity > 0 && (
<button
type="button"
disabled={!canBuy}
onClick={() => onBuy?.(quantity)}
data-testid="buy-price-estimate-buy-button"
>
Buy {quantity} {quantity === 1 ? 'key' : 'keys'}
</button>
)}
</div>
);
}
64 changes: 64 additions & 0 deletions src/components/common/CreatorMarketplaceInfiniteList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useInfiniteCreatorMarketplace } from '@/hooks/useInfiniteCreatorMarketplace';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import CreatorCard from '@/components/common/CreatorCard';
import { CreatorGridSkeleton } from '@/components/common/CreatorSkeleton';
import type { GetCoursesParams } from '@/services/course.service';

export interface CreatorMarketplaceInfiniteListProps {
params?: Omit<GetCoursesParams, 'page'>;
}

/**
* Creator key marketplace listing with IntersectionObserver-driven infinite
* scroll (#685): fetches the first page on mount, then automatically fetches
* subsequent pages via useInfiniteQuery as the user scrolls the sentinel
* element into view. Shows a skeleton row while the next page is loading and
* stops fetching once the backend reports no more pages.
*/
export default function CreatorMarketplaceInfiniteList({
params,
}: CreatorMarketplaceInfiniteListProps) {
const {
creators,
hasMore,
isLoadingFirstPage,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteCreatorMarketplace(params);

const sentinelRef = useInfiniteScroll<HTMLDivElement>({
enabled: !isLoadingFirstPage && !isFetchingNextPage,
hasMore: Boolean(hasMore),
onLoadMore: () => {
void fetchNextPage();
},
});

if (isLoadingFirstPage) {
return (
<div data-testid="creator-marketplace-initial-skeleton">
<CreatorGridSkeleton />
</div>
);
}

return (
<div data-testid="creator-marketplace-infinite-list">
<div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{creators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>

{isFetchingNextPage && (
<div data-testid="creator-marketplace-next-page-skeleton" className="mt-6">
<CreatorGridSkeleton count={3} />
</div>
)}

{hasMore && (
<div ref={sentinelRef} data-testid="creator-marketplace-sentinel" aria-hidden="true" />
)}
</div>
);
}
132 changes: 132 additions & 0 deletions src/components/common/__tests__/BuyPriceEstimate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Unit tests for BuyPriceEstimate — the bonding curve price preview
* component that renders the correct total XLM amount for buying N keys
* (#684).
*/
import { render, screen } from '@testing-library/react';
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import BuyPriceEstimate from '@/components/common/BuyPriceEstimate';
import * as bondingCurveUtils from '@/utils/bondingCurve.utils';
import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';

describe('BuyPriceEstimate', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('displays the correct XLM price for quantity 1', () => {
render(<BuyPriceEstimate currentSupply={0} quantity={1} />);

act(() => {
vi.runAllTimers();
});

const expectedCostStroops = bondingCurveUtils.computeBuyCost(
0,
1,
bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
);
expect(screen.getByTestId('buy-price-total')).toHaveTextContent(
formatDisplayKeyPrice(expectedCostStroops)
);
expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeInTheDocument();
});

it('displays 0 XLM and no buy button for quantity 0', () => {
render(<BuyPriceEstimate currentSupply={0} quantity={0} />);

act(() => {
vi.runAllTimers();
});

expect(screen.getByTestId('buy-price-total')).toHaveTextContent('0 XLM');
expect(
screen.queryByTestId('buy-price-estimate-buy-button')
).not.toBeInTheDocument();
});

it('displays a higher total price for a large quantity than for quantity 1', () => {
const { unmount } = render(<BuyPriceEstimate currentSupply={0} quantity={1} />);
act(() => {
vi.runAllTimers();
});
const priceForOneText = screen.getByTestId('buy-price-total').textContent;
unmount();

render(<BuyPriceEstimate currentSupply={0} quantity={100} />);
act(() => {
vi.runAllTimers();
});
const priceForHundredText = screen.getByTestId('buy-price-total').textContent;

const expectedForHundred = bondingCurveUtils.computeBuyCost(
0,
100,
bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
);
const expectedForOne = bondingCurveUtils.computeBuyCost(
0,
1,
bondingCurveUtils.DEFAULT_BONDING_CURVE_PARAMS
);

expect(expectedForHundred).toBeGreaterThan(expectedForOne);
expect(priceForHundredText).not.toBe(priceForOneText);
expect(priceForHundredText).toContain('XLM');
});

it('updates the displayed price when the quantity prop changes, without unmounting', () => {
const { rerender } = render(<BuyPriceEstimate currentSupply={0} quantity={1} />);
act(() => {
vi.runAllTimers();
});
const firstPrice = screen.getByTestId('buy-price-total').textContent;

rerender(<BuyPriceEstimate currentSupply={0} quantity={5} />);
act(() => {
vi.runAllTimers();
});
const secondPrice = screen.getByTestId('buy-price-total').textContent;

expect(secondPrice).not.toBe(firstPrice);
});

it('shows a loading state while the price is being (re)calculated', () => {
const { rerender } = render(<BuyPriceEstimate currentSupply={0} quantity={1} />);
act(() => {
vi.runAllTimers();
});
expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument();

rerender(<BuyPriceEstimate currentSupply={0} quantity={2} />);
// Before the debounce timer fires, the loading state should be visible
// and the buy button disabled (it stays mounted for layout stability,
// but must not be clickable while the price is stale/recalculating).
expect(screen.getByTestId('buy-price-loading')).toBeInTheDocument();
expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeDisabled();

act(() => {
vi.runAllTimers();
});
expect(screen.queryByTestId('buy-price-loading')).not.toBeInTheDocument();
expect(screen.getByTestId('buy-price-estimate-buy-button')).toBeEnabled();
});

it('calls the bonding curve calculation function with the correct arguments', () => {
const spy = vi.spyOn(bondingCurveUtils, 'computeBuyCost');
const params = { basePriceStroops: 5_000_000, growthFactor: 1.02 };

render(<BuyPriceEstimate currentSupply={42} quantity={7} params={params} />);
act(() => {
vi.runAllTimers();
});

expect(spy).toHaveBeenCalledWith(42, 7, params);
});
});
Loading
Loading