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
200 changes: 200 additions & 0 deletions src/components/common/KeyMintProgressSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React from 'react';
import { ShoppingCart, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import Skeleton from '@/components/ui/skeleton';
import MiniStatChip from '@/components/common/MiniStatChip';
import { formatNumber } from '@/utils/numberFormat.utils';
import { calculateMintProgress } from '@/utils/mintProgress.utils';
import { ERROR_CODE_TO_COPY } from '@/utils/errorHandling.utils';

export interface KeyMintProgressSectionProps {
/** Current minted key supply. */
currentSupply?: number | null;
/** Configured max supply cap. Uncapped if null, undefined, or <= 0. */
maxSupply?: number | null;
/** Loading state for key data. */
isLoading?: boolean;
/** Callback invoked when the user clicks the Buy button. */
onBuy?: () => void;
/** Whether a buy transaction is currently submitting. */
isSubmitting?: boolean;
className?: string;
}

const KeyMintProgressSection: React.FC<KeyMintProgressSectionProps> = ({
currentSupply,
maxSupply,
isLoading = false,
onBuy,
isSubmitting = false,
className,
}) => {
if (isLoading) {
return (
<div
className={cn(
'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8 space-y-4',
className
)}
data-testid="key-mint-progress-skeleton"
aria-busy="true"
aria-label="Loading mint progress"
>
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-36" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
<Skeleton className="h-3 w-full rounded-full" />
<div className="flex justify-end">
<Skeleton className="h-10 w-28 rounded-xl" />
</div>
</div>
);
}

const progress = calculateMintProgress(currentSupply, maxSupply);

// If the key has no max supply configured, hide the progress bar and chip entirely
if (!progress.isCapped) {
return (
<div
className={cn(
'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4',
className
)}
data-testid="key-mint-progress-section"
>
<div>
<h2 className="font-grotesque text-xl font-black tracking-tight text-white">
Key Supply
</h2>
<p className="mt-1 text-sm text-white/55">
Uncapped supply &bull; {formatNumber(progress.currentSupply)} keys minted
</p>
</div>
<Button
type="button"
onClick={onBuy}
disabled={isSubmitting}
aria-disabled={isSubmitting}
data-testid="buy-key-button"
className="rounded-xl font-bold bg-amber-400 text-slate-950 hover:bg-amber-300 active:scale-95 px-6 py-2.5 transition-all"
>
<ShoppingCart className="mr-2 size-4" aria-hidden="true" />
Buy Key
</Button>
</div>
);
}

const maxSupplyFormatted = formatNumber(progress.maxSupply);
const currentSupplyFormatted = formatNumber(progress.currentSupply);
const remainingFormatted = formatNumber(progress.remaining);

return (
<div
className={cn(
'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8 space-y-5',
className
)}
data-testid="key-mint-progress-section"
>
{/* Header with Title and Remaining Mintable Chip */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-grotesque text-xl font-black tracking-tight text-white">
Mint Progress
</h2>
<p className="text-xs text-white/55 mt-0.5">
Supply cap: {maxSupplyFormatted} keys
</p>
</div>
<div data-testid="remaining-mintable-chip">
<MiniStatChip
label="Remaining"
value={`${remainingFormatted} keys`}
explanation="Number of keys remaining before reaching the maximum supply cap."
/>
</div>
</div>

{/* Progress Bar & Percentage Label */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-white/70">
<span>
{currentSupplyFormatted} / {maxSupplyFormatted} minted
</span>
<span
data-testid="mint-progress-percentage"
className="font-bold text-amber-400"
>
{progress.percentageLabel}
</span>
</div>

<div
role="progressbar"
aria-valuenow={progress.currentSupply}
aria-valuemin={0}
aria-valuemax={progress.maxSupply ?? undefined}
aria-label="Key mint progress"
aria-valuetext={`${progress.percentageLabel} minted (${currentSupplyFormatted} of ${maxSupplyFormatted} keys)`}
className="relative h-3 w-full overflow-hidden rounded-full bg-white/10"
data-testid="mint-progress-bar"
>
<div
className={cn(
'h-full rounded-full transition-all duration-300',
progress.isAtCap
? 'bg-amber-400'
: 'bg-gradient-to-r from-amber-500 to-amber-300'
)}
style={{ width: `${progress.percentage}%` }}
data-testid="mint-progress-fill"
/>
</div>
</div>

{/* Action & Cap Warning Row */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pt-1">
{progress.isAtCap ? (
<div
id="max-supply-reached-message"
role="status"
aria-live="polite"
data-testid="max-supply-message"
className="flex items-center gap-2 text-xs font-medium text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-xl px-3.5 py-2.5"
>
<AlertCircle className="size-4 shrink-0 text-amber-400" aria-hidden="true" />
<span>{ERROR_CODE_TO_COPY.MaxSupplyReached}</span>
</div>
) : (
<div className="text-xs text-white/45">
Keys are minted on-demand via the bonding curve until the cap is reached.
</div>
)}

<Button
type="button"
onClick={onBuy}
disabled={progress.isAtCap || isSubmitting}
aria-disabled={progress.isAtCap || isSubmitting}
aria-describedby={progress.isAtCap ? 'max-supply-reached-message' : undefined}
data-testid="buy-key-button"
className={cn(
'rounded-xl font-bold px-6 py-2.5 transition-all shrink-0',
progress.isAtCap
? 'opacity-60 cursor-not-allowed bg-white/10 text-white/40 border border-white/10'
: 'bg-amber-400 text-slate-950 hover:bg-amber-300 active:scale-95'
)}
>
<ShoppingCart className="mr-2 size-4" aria-hidden="true" />
{progress.isAtCap ? 'Cap Reached' : 'Buy Key'}
</Button>
</div>
</div>
);
};

export default KeyMintProgressSection;
162 changes: 162 additions & 0 deletions src/components/common/__tests__/KeyMintProgressSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import KeyMintProgressSection from '@/components/common/KeyMintProgressSection';
import { ERROR_CODE_TO_COPY } from '@/utils/errorHandling.utils';

describe('KeyMintProgressSection Component', () => {
it('renders progress bar with accurate current and max values and progressbar semantics (acceptance #1)', () => {
render(
<KeyMintProgressSection
currentSupply={400}
maxSupply={1000}
/>
);

const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveAttribute('aria-valuenow', '400');
expect(progressbar).toHaveAttribute('aria-valuemin', '0');
expect(progressbar).toHaveAttribute('aria-valuemax', '1000');
expect(progressbar).toHaveAttribute('aria-label', 'Key mint progress');

expect(screen.getByText('400 / 1,000 minted')).toBeInTheDocument();
expect(screen.getByText('Supply cap: 1,000 keys')).toBeInTheDocument();
});

it('computes and displays percentage label correctly across edge cases (acceptance #2)', () => {
// 0%
const { rerender } = render(
<KeyMintProgressSection currentSupply={0} maxSupply={100} />
);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('0%');

// Partial (50%)
rerender(<KeyMintProgressSection currentSupply={50} maxSupply={100} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('50%');

// Tiny fraction (1 of 2,000 = 0.05%)
rerender(<KeyMintProgressSection currentSupply={1} maxSupply={2000} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('0.05%');

// Tiny fraction (<0.01%)
rerender(<KeyMintProgressSection currentSupply={1} maxSupply={100000} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('<0.01%');

// Close to cap without reaching it (999 of 1000 = 99.9% - never 100% prematurely)
rerender(<KeyMintProgressSection currentSupply={999} maxSupply={1000} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('99.9%');

// Exactly at cap (100%)
rerender(<KeyMintProgressSection currentSupply={100} maxSupply={100} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('100%');

// Over cap (clamped to 100%)
rerender(<KeyMintProgressSection currentSupply={110} maxSupply={100} />);
expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('100%');
});

it('renders remaining mintable chip showing accurate remaining amount (acceptance #3)', () => {
const { rerender } = render(
<KeyMintProgressSection currentSupply={35} maxSupply={100} />
);

const remainingChip = screen.getByTestId('remaining-mintable-chip');
expect(remainingChip).toBeInTheDocument();
expect(remainingChip).toHaveTextContent('65 keys');

// At cap -> 0 keys remaining (never negative)
rerender(<KeyMintProgressSection currentSupply={100} maxSupply={100} />);
expect(screen.getByTestId('remaining-mintable-chip')).toHaveTextContent('0 keys');

// Over cap -> still 0 keys remaining (never negative)
rerender(<KeyMintProgressSection currentSupply={120} maxSupply={100} />);
expect(screen.getByTestId('remaining-mintable-chip')).toHaveTextContent('0 keys');
});

it('disables buy button at cap and shows the MaxSupplyReached message with explanation (acceptance #4)', async () => {
const handleBuy = vi.fn();
const user = userEvent.setup();

render(
<KeyMintProgressSection
currentSupply={100}
maxSupply={100}
onBuy={handleBuy}
/>
);

const buyButton = screen.getByTestId('buy-key-button');
expect(buyButton).toBeDisabled();
expect(buyButton).toHaveAttribute('aria-disabled', 'true');
expect(buyButton).toHaveAttribute('aria-describedby', 'max-supply-reached-message');

const message = screen.getByTestId('max-supply-message');
expect(message).toBeInTheDocument();
expect(message).toHaveTextContent(ERROR_CODE_TO_COPY.MaxSupplyReached);

await user.click(buyButton);
expect(handleBuy).not.toHaveBeenCalled();
});

it('hides progress bar and remaining chip for uncapped keys and leaves buy button functional (acceptance #5)', async () => {
const handleBuy = vi.fn();
const user = userEvent.setup();

const { rerender } = render(
<KeyMintProgressSection
currentSupply={75}
maxSupply={null}
onBuy={handleBuy}
/>
);

// Progress bar and remaining chip should be completely hidden
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('mint-progress-bar')).not.toBeInTheDocument();
expect(screen.queryByTestId('remaining-mintable-chip')).not.toBeInTheDocument();
expect(screen.queryByTestId('max-supply-message')).not.toBeInTheDocument();

// Buy button should be present, enabled, and functional
const buyButton = screen.getByTestId('buy-key-button');
expect(buyButton).toBeInTheDocument();
expect(buyButton).not.toBeDisabled();
expect(buyButton).toHaveTextContent(/buy key/i);

await user.click(buyButton);
expect(handleBuy).toHaveBeenCalledTimes(1);

// Same behavior for undefined or 0 max supply
rerender(
<KeyMintProgressSection
currentSupply={75}
maxSupply={undefined}
onBuy={handleBuy}
/>
);
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('remaining-mintable-chip')).not.toBeInTheDocument();

rerender(
<KeyMintProgressSection
currentSupply={75}
maxSupply={0}
onBuy={handleBuy}
/>
);
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('remaining-mintable-chip')).not.toBeInTheDocument();
});

it('renders loading skeleton while isLoading is true without flashing undefined or NaN', () => {
render(
<KeyMintProgressSection
isLoading={true}
/>
);

expect(screen.getByTestId('key-mint-progress-skeleton')).toBeInTheDocument();
expect(screen.queryByText(/nan/i)).not.toBeInTheDocument();
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useKeyHolders } from '@/hooks/useKeyHolders';
import { useProfileStore } from '@/hooks/useProfileStore';
import { useWalletHoldings, useTradeMutation } from '@/hooks/useWallet';
import KeyMintProgressSection from '@/components/common/KeyMintProgressSection';
import CoCreatorSection from '@/components/creator/CoCreatorSection';
import ShareTwitterButton from '@/components/common/ShareTwitterButton';
import { PriceHistoryChart } from '@/components/common/PriceHistoryChart';
Expand Down Expand Up @@ -638,6 +639,18 @@ function CreatorDetailPageContent() {
<CreatorProfileStatRow items={statItems} />
</div>

{/* Mint Progress Bar & Supply Cap */}
<KeyMintProgressSection
currentSupply={creator.creatorShareSupply}
maxSupply={creator.maxSupply}
isLoading={isLoading}
onBuy={() => {
setTradeSide('buy');
setBuyDialogOpen(true);
}}
isSubmitting={tradeSubmitting || tradeMutation.isPending}
/>

<SectionErrorBoundary sectionName="key statistics">
<KeyStatsPanel
stats={keyStats}
Expand Down
Loading
Loading