From 9b29ba1bb3ee08ff866a3145198fb8f8826fccfd Mon Sep 17 00:00:00 2001 From: chidii Date: Wed, 30 Sep 2026 22:18:31 -0700 Subject: [PATCH] feat(keys): supply cap progress indicator on key detail page (#1035) --- src/components/common/SupplyCapProgress.tsx | 135 ++++++++++++++++++ .../__tests__/SupplyCapProgress.test.tsx | 57 ++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/components/common/SupplyCapProgress.tsx create mode 100644 src/components/common/__tests__/SupplyCapProgress.test.tsx diff --git a/src/components/common/SupplyCapProgress.tsx b/src/components/common/SupplyCapProgress.tsx new file mode 100644 index 00000000..7429815c --- /dev/null +++ b/src/components/common/SupplyCapProgress.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; + +export interface SupplyCapProgressProps { + current?: number | null; + cap?: number | null; + isLoading?: boolean; + isError?: boolean; + className?: string; +} + +function ratioFor(current: number, cap: number): number { + if (cap <= 0) return 0; + return Math.min(1, Math.max(0, current / cap)); +} + +const SupplyCapProgress: React.FC = ({ + current, + cap, + isLoading = false, + isError = false, + className, +}) => { + const hasCap = cap != null && cap > 0; + if (!hasCap) return null; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (isError || current == null) { + return ( +
+ Supply cap information is temporarily unavailable. +
+ ); + } + + const safeCurrent = Math.max(0, current); + const ratio = ratioFor(safeCurrent, cap); + const percent = Math.round(ratio * 100); + const remaining = Math.max(0, cap - safeCurrent); + const soldOut = safeCurrent >= cap; + + return ( +
+
+
+

+ Supply Cap +

+ {soldOut && ( + + Sold out + + )} +
+

+ {percent}% +

+
+ +
+
+
+ +
+ + + {formatNumber(safeCurrent)} + {' '} + of{' '} + + {formatNumber(cap)} + {' '} + keys sold + + + {remaining > 0 + ? `${formatCompactNumber(remaining)} remaining` + : 'No keys remaining'} + +
+
+ ); +}; + +export default SupplyCapProgress; \ No newline at end of file diff --git a/src/components/common/__tests__/SupplyCapProgress.test.tsx b/src/components/common/__tests__/SupplyCapProgress.test.tsx new file mode 100644 index 00000000..1e2f7aac --- /dev/null +++ b/src/components/common/__tests__/SupplyCapProgress.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import SupplyCapProgress from '../SupplyCapProgress'; + +describe('SupplyCapProgress', () => { + it('renders nothing when cap is null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when cap is zero', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when cap is undefined', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows correct percent for a partial fill', () => { + render(); + expect(screen.getByTestId('supply-cap-progress-percent')).toHaveTextContent('25%'); + expect(screen.getByTestId('supply-cap-progress-current')).toHaveTextContent(/25/); + expect(screen.getByTestId('supply-cap-progress-current')).toHaveTextContent(/100/); + }); + + it('shows sold-out badge when current equals cap', () => { + render(); + expect(screen.getByTestId('supply-cap-progress-sold-out-badge')).toBeInTheDocument(); + expect(screen.getByTestId('supply-cap-progress-percent')).toHaveTextContent('100%'); + expect(screen.getByTestId('supply-cap-progress-remaining')).toHaveTextContent(/No keys remaining/); + }); + + it('shows sold-out badge when current exceeds cap', () => { + render(); + expect(screen.getByTestId('supply-cap-progress-sold-out-badge')).toBeInTheDocument(); + expect(screen.getByTestId('supply-cap-progress-percent')).toHaveTextContent('100%'); + }); + + it('caps the progress bar width at 100%', () => { + render(); + const fill = screen.getByTestId('supply-cap-progress-fill'); + expect(fill).toHaveStyle({ width: '100%' }); + }); + + it('shows loading skeleton without rendering the bar', () => { + render(); + expect(screen.getByTestId('supply-cap-progress-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('supply-cap-progress')).not.toBeInTheDocument(); + }); + + it('shows error state when isError', () => { + render(); + expect(screen.getByTestId('supply-cap-progress-error')).toBeInTheDocument(); + }); +}); \ No newline at end of file