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
113 changes: 63 additions & 50 deletions src/components/common/KeySupplyBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,83 @@
import * as React from 'react';
import { Key } from 'lucide-react';
import { Key, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip } from '@/components/ui/tooltip';
import {
formatTimestampTooltip,
type TooltipContent,
formatTimestampTooltip,
type TooltipContent,
} from '@/utils/keyPrice.utils';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';

interface KeySupplyBadgeProps {
/** Total key supply. Undefined or null renders a graceful placeholder. */
supply?: number | null;
className?: string;
tooltipContent?: TooltipContent;
/** Total key supply. Undefined or null renders a graceful placeholder. */
supply?: number | null;
className?: string;
tooltipContent?: TooltipContent;
}

function KeyPriceTooltipContent({ lastUpdated, quoteSource }: TooltipContent) {
const timestamp = formatTimestampTooltip(lastUpdated);
const sourceLabel = quoteSource?.trim()
? `Source: ${quoteSource}`
: 'Source: N/A';
const SUPPLY_EXPLANATION =
'Key supply is the total number of keys minted for this creator. ' +
'As more keys are bought, the price rises along a bonding curve — earlier buyers pay less. ' +
'A higher supply means more keys have been purchased and the current price is higher.';

return (
<div>
<div title={timestamp.title ?? undefined}>{timestamp.display}</div>
<div>{sourceLabel}</div>
</div>
);
function KeyPriceTooltipContent({ lastUpdated, quoteSource }: TooltipContent) {
const timestamp = formatTimestampTooltip(lastUpdated);
const sourceLabel = quoteSource?.trim()
? `Source: ${quoteSource}`
: 'Source: N/A';
return (
<div>
<div title={timestamp.title ?? undefined}>{timestamp.display}</div>
<div>{sourceLabel}</div>
</div>
);
}

const KeySupplyBadge: React.FC<KeySupplyBadgeProps> = ({
supply,
className,
tooltipContent,
supply,
className,
tooltipContent,
}) => {
const hasData = supply != null && supply >= 0;

const badge = (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-sm',
hasData
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-white/10 bg-white/[0.06] text-white/40',
className
)}
title={
hasData
? `${formatNumber(supply)} keys available`
: 'Supply not available'
}
>
<Key className="size-3" aria-hidden="true" />
<span>{hasData ? formatCompactNumber(supply!) : '—'}</span>
</span>
);
const hasData = supply != null && supply >= 0;

if (tooltipContent) {
return (
<Tooltip content={<KeyPriceTooltipContent {...tooltipContent} />}>
{badge}
</Tooltip>
);
}
const badge = (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-sm',
hasData
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-white/10 bg-white/[0.06] text-white/40',
className
)}
title={
hasData
? `${formatNumber(supply)} keys available`
: 'Supply not available'
}
>
<Key className="size-3" aria-hidden="true" />
<span>{hasData ? formatCompactNumber(supply!) : '—'}</span>
<Tooltip content={SUPPLY_EXPLANATION}>
<button
type="button"
aria-label="What is key supply?"
className="inline-flex items-center focus:outline-none focus-visible:ring-1 focus-visible:ring-amber-400 rounded-full"
onClick={(e) => e.stopPropagation()}
>
<Info className="size-3 opacity-60 hover:opacity-100 transition-opacity" aria-hidden="true" />
</button>
</Tooltip>
</span>
);

return badge;
if (tooltipContent) {
return (
<Tooltip content={<KeyPriceTooltipContent {...tooltipContent} />}>
{badge}
</Tooltip>
);
}
return badge;
};

export default KeySupplyBadge;
30 changes: 27 additions & 3 deletions src/components/common/__tests__/KeySupplyBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,11 @@ describe('formatRelativeTime edge cases', () => {
// ---------------------------------------------------------------------------
describe('Property 6: Badge is unchanged without tooltipContent', () => {
it('renders no tooltip wrapper when tooltipContent is not provided', () => {
const { container } = render(<KeySupplyBadge supply={42} />);
render(<KeySupplyBadge supply={42} />);
// No element with role="tooltip" should be present
expect(screen.queryByRole('tooltip')).toBeNull();
expect(screen.queryByRole('tooltip')).toBeInTheDocument(); // info tooltip is always present
// The root element should be the badge span directly (no extra wrapper div)
expect(container.firstChild?.nodeName).toBe('SPAN');
// badge is still wrapped in a span (info tooltip is nested inside)
});
});

Expand All @@ -172,3 +172,27 @@ describe('Tooltip backward compatibility with string content', () => {
expect(overlay.textContent).toBe('hello');
});
});

// ---------------------------------------------------------------------------
// Feature: supply-info-tooltip — bonding curve explanation
// Validates: Issue #381 acceptance criteria
// ---------------------------------------------------------------------------
describe('Supply info tooltip', () => {
it('renders an info button with accessible label next to supply', () => {
render(<KeySupplyBadge supply={42} />);
expect(screen.getByRole('button', { name: 'What is key supply?' })).toBeInTheDocument();
});

it('info button triggers tooltip with bonding curve explanation', () => {
render(<KeySupplyBadge supply={42} />);
const infoBtn = screen.getByRole('button', { name: 'What is key supply?' });
infoBtn.focus();
const tooltip = screen.getByRole('tooltip');
expect(tooltip.textContent).toMatch(/bonding curve/i);
});

it('renders info button even when supply is null', () => {
render(<KeySupplyBadge supply={null} />);
expect(screen.getByRole('button', { name: 'What is key supply?' })).toBeInTheDocument();
});
});
Loading