diff --git a/src/components/common/KeyMintProgressSection.tsx b/src/components/common/KeyMintProgressSection.tsx new file mode 100644 index 00000000..851abf97 --- /dev/null +++ b/src/components/common/KeyMintProgressSection.tsx @@ -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 = ({ + currentSupply, + maxSupply, + isLoading = false, + onBuy, + isSubmitting = false, + className, +}) => { + if (isLoading) { + return ( +
+
+ + +
+ +
+ +
+
+ ); + } + + const progress = calculateMintProgress(currentSupply, maxSupply); + + // If the key has no max supply configured, hide the progress bar and chip entirely + if (!progress.isCapped) { + return ( +
+
+

+ Key Supply +

+

+ Uncapped supply • {formatNumber(progress.currentSupply)} keys minted +

+
+ +
+ ); + } + + const maxSupplyFormatted = formatNumber(progress.maxSupply); + const currentSupplyFormatted = formatNumber(progress.currentSupply); + const remainingFormatted = formatNumber(progress.remaining); + + return ( +
+ {/* Header with Title and Remaining Mintable Chip */} +
+
+

+ Mint Progress +

+

+ Supply cap: {maxSupplyFormatted} keys +

+
+
+ +
+
+ + {/* Progress Bar & Percentage Label */} +
+
+ + {currentSupplyFormatted} / {maxSupplyFormatted} minted + + + {progress.percentageLabel} + +
+ +
+
+
+
+ + {/* Action & Cap Warning Row */} +
+ {progress.isAtCap ? ( +
+
+ ) : ( +
+ Keys are minted on-demand via the bonding curve until the cap is reached. +
+ )} + + +
+
+ ); +}; + +export default KeyMintProgressSection; diff --git a/src/components/common/__tests__/KeyMintProgressSection.test.tsx b/src/components/common/__tests__/KeyMintProgressSection.test.tsx new file mode 100644 index 00000000..1a200117 --- /dev/null +++ b/src/components/common/__tests__/KeyMintProgressSection.test.tsx @@ -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( + + ); + + 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( + + ); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('0%'); + + // Partial (50%) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('50%'); + + // Tiny fraction (1 of 2,000 = 0.05%) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('0.05%'); + + // Tiny fraction (<0.01%) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('<0.01%'); + + // Close to cap without reaching it (999 of 1000 = 99.9% - never 100% prematurely) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('99.9%'); + + // Exactly at cap (100%) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('100%'); + + // Over cap (clamped to 100%) + rerender(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('100%'); + }); + + it('renders remaining mintable chip showing accurate remaining amount (acceptance #3)', () => { + const { rerender } = render( + + ); + + const remainingChip = screen.getByTestId('remaining-mintable-chip'); + expect(remainingChip).toBeInTheDocument(); + expect(remainingChip).toHaveTextContent('65 keys'); + + // At cap -> 0 keys remaining (never negative) + rerender(); + expect(screen.getByTestId('remaining-mintable-chip')).toHaveTextContent('0 keys'); + + // Over cap -> still 0 keys remaining (never negative) + rerender(); + 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( + + ); + + 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( + + ); + + // 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( + + ); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('remaining-mintable-chip')).not.toBeInTheDocument(); + + rerender( + + ); + 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( + + ); + + expect(screen.getByTestId('key-mint-progress-skeleton')).toBeInTheDocument(); + expect(screen.queryByText(/nan/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 8eb1c3f4..081a7600 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -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'; @@ -638,6 +639,18 @@ function CreatorDetailPageContent() {
+ {/* Mint Progress Bar & Supply Cap */} + { + setTradeSide('buy'); + setBuyDialogOpen(true); + }} + isSubmitting={tradeSubmitting || tradeMutation.isPending} + /> + { expect( await screen.findByTestId('early-access-badge') ).toBeInTheDocument(); - const buyButton = screen.getByTestId('creator-buy-button'); + const buyButton = + screen.queryByTestId('creator-buy-button') ?? + screen.getByTestId('key-detail-buy-button'); expect(buyButton).toBeDisabled(); expect(screen.getByRole('tooltip')).toHaveTextContent( 'Your wallet is not on the whitelist' @@ -484,4 +486,97 @@ describe('CreatorDetailPage Integration', () => { await screen.findByText(/only whitelisted wallets can buy/i) ).toBeInTheDocument(); }); + + it('renders mint progress bar and remaining chip when maxSupply is configured', async () => { + mockGetCourse.mockResolvedValue({ + id: 'creator-capped', + title: 'Capped Creator', + description: 'Has supply cap', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 150, + maxSupply: 300, + instructorId: 'capped', + category: 'Art', + level: 'BEGINNER', + }); + + render( + + + + } /> + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('50%'); + expect(screen.getByTestId('remaining-mintable-chip')).toHaveTextContent('150 keys'); + expect(screen.getByTestId('buy-key-button')).not.toBeDisabled(); + }); + + it('disables buy button at cap and displays MaxSupplyReached message on key detail', async () => { + mockGetCourse.mockResolvedValue({ + id: 'creator-maxed', + title: 'Maxed Creator', + description: 'Has hit supply cap', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 250, + maxSupply: 250, + instructorId: 'maxed', + category: 'Art', + level: 'BEGINNER', + }); + + render( + + + + } /> + + + + ); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByTestId('mint-progress-percentage')).toHaveTextContent('100%'); + expect(screen.getByTestId('remaining-mintable-chip')).toHaveTextContent('0 keys'); + expect(screen.getByTestId('buy-key-button')).toBeDisabled(); + expect(screen.getByTestId('max-supply-message')).toHaveTextContent( + /maximum supply reached/i + ); + }); + + it('hides progress bar and remaining chip when key has no maxSupply configured', async () => { + mockGetCourse.mockResolvedValue({ + id: 'creator-uncapped', + title: 'Uncapped Creator', + description: 'No supply cap', + price: 0.1, + priceStroops: 1_000_000, + creatorShareSupply: 100, + maxSupply: null, + instructorId: 'uncapped', + category: 'Art', + level: 'BEGINNER', + }); + + render( + + + + } /> + + + + ); + + expect(await screen.findByText('Uncapped Creator Profile')).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(screen.queryByTestId('remaining-mintable-chip')).not.toBeInTheDocument(); + expect(screen.getByTestId('buy-key-button')).not.toBeDisabled(); + }); }); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index afe77746..d96da246 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -14,6 +14,8 @@ export interface Course { /** ISO timestamp for the next scheduled drop, when applicable. */ nextDropAt?: string; creatorShareSupply?: number; + /** Configured max supply cap for this key; null or undefined for uncapped keys. */ + maxSupply?: number | null; instructorId: string; thumbnail?: string; category: string; diff --git a/src/utils/__tests__/mintProgress.utils.test.ts b/src/utils/__tests__/mintProgress.utils.test.ts new file mode 100644 index 00000000..526189b1 --- /dev/null +++ b/src/utils/__tests__/mintProgress.utils.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateMintProgress, + formatMintPercentage, +} from '@/utils/mintProgress.utils'; + +describe('formatMintPercentage', () => { + it('formats 0% correctly for zero current supply', () => { + expect(formatMintPercentage(0, 100)).toBe('0%'); + }); + + it('formats partial percentage correctly', () => { + expect(formatMintPercentage(50, 100)).toBe('50%'); + expect(formatMintPercentage(25, 100)).toBe('25%'); + expect(formatMintPercentage(33, 100)).toBe('33%'); + }); + + it('formats exactly at cap as 100%', () => { + expect(formatMintPercentage(100, 100)).toBe('100%'); + expect(formatMintPercentage(500, 500)).toBe('100%'); + }); + + it('formats over cap safely as 100%', () => { + expect(formatMintPercentage(105, 100)).toBe('100%'); + expect(formatMintPercentage(200, 100)).toBe('100%'); + }); + + it('handles tiny fractions safely without divide-by-zero or showing 0%', () => { + // 1 out of 2,000 = 0.05% + expect(formatMintPercentage(1, 2000)).toBe('0.05%'); + // 1 out of 1,000 = 0.1% + expect(formatMintPercentage(1, 1000)).toBe('0.1%'); + // 1 out of 200 = 0.5% + expect(formatMintPercentage(1, 200)).toBe('0.5%'); + // very small fraction + expect(formatMintPercentage(1, 100000)).toBe('<0.01%'); + }); + + it('does not show 100% unless supply has actually reached the cap', () => { + // 999 out of 1000 is 99.9% + expect(formatMintPercentage(999, 1000)).toBe('99.9%'); + // 9999 out of 10000 is 99.99% - must not round to 100% + expect(formatMintPercentage(9999, 10000)).toBe('99.9%'); + }); + + it('handles invalid / non-positive max gracefully', () => { + expect(formatMintPercentage(50, 0)).toBe('0%'); + expect(formatMintPercentage(50, -10)).toBe('0%'); + }); + + it('handles negative current gracefully', () => { + expect(formatMintPercentage(-5, 100)).toBe('0%'); + }); +}); + +describe('calculateMintProgress', () => { + it('identifies uncapped keys when maxSupply is null, undefined, 0, or negative', () => { + expect(calculateMintProgress(50, null)).toEqual({ + isCapped: false, + currentSupply: 50, + maxSupply: null, + remaining: 0, + percentage: 0, + percentageLabel: '0%', + isAtCap: false, + }); + + expect(calculateMintProgress(50, undefined).isCapped).toBe(false); + expect(calculateMintProgress(50, 0).isCapped).toBe(false); + expect(calculateMintProgress(50, -100).isCapped).toBe(false); + }); + + it('computes accurate current, max, remaining, and percentage for capped keys', () => { + const result = calculateMintProgress(40, 100); + expect(result).toEqual({ + isCapped: true, + currentSupply: 40, + maxSupply: 100, + remaining: 60, + percentage: 40, + percentageLabel: '40%', + isAtCap: false, + }); + }); + + it('computes remaining accurately and never allows it to be negative', () => { + expect(calculateMintProgress(100, 100).remaining).toBe(0); + expect(calculateMintProgress(120, 100).remaining).toBe(0); + expect(calculateMintProgress(99, 100).remaining).toBe(1); + }); + + it('sets isAtCap to true when currentSupply >= maxSupply', () => { + expect(calculateMintProgress(99, 100).isAtCap).toBe(false); + expect(calculateMintProgress(100, 100).isAtCap).toBe(true); + expect(calculateMintProgress(105, 100).isAtCap).toBe(true); + }); + + it('clamps percentage between 0 and 100', () => { + expect(calculateMintProgress(-10, 100).percentage).toBe(0); + expect(calculateMintProgress(0, 100).percentage).toBe(0); + expect(calculateMintProgress(50, 100).percentage).toBe(50); + expect(calculateMintProgress(150, 100).percentage).toBe(100); + }); + + it('handles edge case when currentSupply is undefined or null', () => { + const result = calculateMintProgress(null, 100); + expect(result.currentSupply).toBe(0); + expect(result.remaining).toBe(100); + expect(result.percentage).toBe(0); + expect(result.percentageLabel).toBe('0%'); + expect(result.isAtCap).toBe(false); + }); +}); diff --git a/src/utils/errorHandling.utils.ts b/src/utils/errorHandling.utils.ts index fd254cb2..85b1a334 100644 --- a/src/utils/errorHandling.utils.ts +++ b/src/utils/errorHandling.utils.ts @@ -9,8 +9,17 @@ export const WALLET_ERROR_COPY = { SIGNATURE_REJECTED: "Signature request was declined. Please try again when you're ready to confirm.", SIGNATURE_FAILED: "The signature request failed. Please ensure your wallet is unlocked and try again.", GENERIC_TRANSACTION_FAILED: "Transaction failed. Please check your balance or connection and try again.", + MaxSupplyReached: "Maximum supply reached. No more keys can be minted.", }; +export const ERROR_CODE_TO_COPY: Record = { + ...WALLET_ERROR_COPY, +}; + +export function getErrorMessage(code: string): string { + return ERROR_CODE_TO_COPY[code] ?? code; +} + interface WalletError { message?: string; code?: number | string; diff --git a/src/utils/mintProgress.utils.ts b/src/utils/mintProgress.utils.ts new file mode 100644 index 00000000..4d214cdb --- /dev/null +++ b/src/utils/mintProgress.utils.ts @@ -0,0 +1,101 @@ +/** + * Pure calculation helpers for creator key mint progress and supply cap. + */ + +export interface MintProgressResult { + /** Whether a max supply cap is configured and valid (> 0). */ + isCapped: boolean; + /** Safe normalized current supply (>= 0). */ + currentSupply: number; + /** Configured max supply cap, or null if uncapped. */ + maxSupply: number | null; + /** Remaining mintable keys before cap is hit (maxSupply - currentSupply, never negative). */ + remaining: number; + /** Progress percentage clamped to 0–100. */ + percentage: number; + /** User-facing percentage string. Never shows 100% unless currentSupply >= maxSupply. */ + percentageLabel: string; + /** True when currentSupply >= maxSupply. */ + isAtCap: boolean; +} + +/** + * Format mint progress as a safe percentage string. + * + * Requirements: + * - Clamp between 0% and 100% + * - Safe divide-by-zero + * - Sensible rounding: never show 100% unless supply has actually reached the cap + * - Handles tiny fractions cleanly (e.g. 0.05%, <0.01%) + */ +export function formatMintPercentage(current: number, max: number): string { + if (max <= 0 || !Number.isFinite(max)) return '0%'; + if (current >= max) return '100%'; + if (current <= 0 || !Number.isFinite(current)) return '0%'; + + const raw = (current / max) * 100; + + if (raw >= 100) return '100%'; + + // Tiny fractions (< 1%) + if (raw < 1) { + const formatted = parseFloat(raw.toFixed(2)); + if (formatted <= 0) return '<0.01%'; + return `${formatted}%`; + } + + // Between 1% and 100%: ensure we never round up to 100% prematurely + const rounded = Math.round(raw); + if (rounded >= 100) { + return '99.9%'; + } + return `${rounded}%`; +} + +/** + * Computes mint progress and supply cap state from current and max supply. + * + * An uncapped key has null, undefined, or <= 0 max supply. + */ +export function calculateMintProgress( + currentSupply?: number | null, + maxSupply?: number | null +): MintProgressResult { + const safeMax = + maxSupply != null && Number.isFinite(maxSupply) && maxSupply > 0 + ? maxSupply + : null; + const isCapped = safeMax !== null; + const safeCurrent = + currentSupply != null && Number.isFinite(currentSupply) && currentSupply > 0 + ? currentSupply + : 0; + + if (!isCapped) { + return { + isCapped: false, + currentSupply: safeCurrent, + maxSupply: null, + remaining: 0, + percentage: 0, + percentageLabel: '0%', + isAtCap: false, + }; + } + + const remaining = Math.max(0, safeMax - safeCurrent); + const isAtCap = safeCurrent >= safeMax; + const rawPercentage = (safeCurrent / safeMax) * 100; + const percentage = Math.min(100, Math.max(0, rawPercentage)); + const percentageLabel = formatMintPercentage(safeCurrent, safeMax); + + return { + isCapped: true, + currentSupply: safeCurrent, + maxSupply: safeMax, + remaining, + percentage, + percentageLabel, + isAtCap, + }; +}