diff --git a/src/components/common/Change24hBadge.tsx b/src/components/common/Change24hBadge.tsx index 877f597..ad99579 100644 --- a/src/components/common/Change24hBadge.tsx +++ b/src/components/common/Change24hBadge.tsx @@ -1,5 +1,6 @@ import { TrendingUp, TrendingDown, Minus } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { formatPercent } from '@/utils/numberFormat.utils'; interface Change24hBadgeProps { /** Percentage change over 24h. Positive = up, negative = down, undefined = no data. */ @@ -10,6 +11,7 @@ interface Change24hBadgeProps { const Change24hBadge: React.FC = ({ change, className }) => { const isPositive = change !== undefined && change > 0; const isNegative = change !== undefined && change < 0; + const formatted = formatPercent(change, { signed: true }); return (
= ({ change, className }) => !isPositive && !isNegative && 'border-white/10 bg-white/[0.06] text-white/40', className )} - title={change !== undefined ? `${change > 0 ? '+' : ''}${change.toFixed(2)}% (24h)` : 'No 24h data'} + title={change !== undefined ? `${formatted} (24h)` : 'No 24h data'} > {isPositive && } {isNegative && } {!isPositive && !isNegative && } - - {change !== undefined - ? `${change > 0 ? '+' : ''}${change.toFixed(2)}%` - : '—'} - + {formatted}
); }; diff --git a/src/components/common/CreatorBio.tsx b/src/components/common/CreatorBio.tsx new file mode 100644 index 0000000..5b8bd97 --- /dev/null +++ b/src/components/common/CreatorBio.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/lib/utils'; + +interface CreatorBioProps { + /** Raw bio string from the creator profile. Anything falsy or whitespace-only is treated as missing. */ + bio?: string | null; + /** Override the default fallback copy. */ + fallback?: string; + /** Variant — `card` is muted/italic for list rows, `profile` is slightly more prominent for the detail header. */ + variant?: 'card' | 'profile'; + className?: string; +} + +const DEFAULT_FALLBACK = "This creator hasn't shared a bio yet."; + +const variantClasses: Record<'card' | 'profile', { value: string; fallback: string }> = { + card: { + value: 'text-sm text-white/60 leading-relaxed', + fallback: 'text-xs italic text-white/35', + }, + profile: { + value: 'font-jakarta text-sm text-white/70 leading-relaxed', + fallback: 'font-jakarta text-sm italic text-white/40', + }, +}; + +/** + * Renders a creator bio with a consistent fallback when the bio is missing. + * + * Centralizing this keeps wording aligned across the list and profile detail + * surfaces — change the fallback once and every consumer picks it up. + */ +const CreatorBio: React.FC = ({ + bio, + fallback = DEFAULT_FALLBACK, + variant = 'card', + className, +}) => { + const trimmed = bio?.trim(); + const styles = variantClasses[variant]; + + if (!trimmed) { + return ( +

+ {fallback} +

+ ); + } + + return

{trimmed}

; +}; + +export default CreatorBio; diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 218f4c8..1bf4740 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -22,6 +22,7 @@ import KeySupplyBadge from '@/components/common/KeySupplyBadge'; import CreatorListRowDivider from '@/components/common/CreatorListRowDivider'; import BuyActionHelperText from '@/components/common/BuyActionHelperText'; import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow'; +import CreatorBio from '@/components/common/CreatorBio'; import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -151,6 +152,8 @@ const CreatorCard: React.FC = ({ creator, className }) => { @{creator.instructorId || 'creator'}

+ + {creator.socialHandle ? (
diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index b360f51..e316a7c 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import CreatorBio from '@/components/common/CreatorBio'; interface CreatorProfileHeaderProps { name: string; @@ -12,6 +13,7 @@ interface CreatorProfileHeaderProps { creatorId?: string | number | null; avatarUrl?: string; isVerified?: boolean; + bio?: string | null; className?: string; } @@ -21,6 +23,7 @@ const CreatorProfileHeader: React.FC = ({ creatorId, avatarUrl, isVerified, + bio, className, }) => { const [copied, setCopied] = useState(false); @@ -75,6 +78,7 @@ const CreatorProfileHeader: React.FC = ({ {isVerified && }

@{handle}

+ diff --git a/src/components/common/FormFileUpload.tsx b/src/components/common/FormFileUpload.tsx index 55acdee..5f4589e 100644 --- a/src/components/common/FormFileUpload.tsx +++ b/src/components/common/FormFileUpload.tsx @@ -1,6 +1,10 @@ import React, { Fragment, useEffect, useState } from 'react'; import { Trash2, RefreshCw, CloudUpload, X, Check } from 'lucide-react'; import { Alert } from '../common/Alert'; +import { + isFileAccepted, + unsupportedFormatMessage, +} from '@/utils/fileFormat.utils'; type FileUploadStatus = | 'initial' @@ -8,6 +12,7 @@ type FileUploadStatus = | 'success' | 'error' | 'too-large' + | 'unsupported-format' | 'failed'; // Configuration props for the component @@ -65,6 +70,22 @@ const FormFileUpload: React.FC = ({ }, [id, value]); const simulateUpload = (file: File) => { + // Reject unsupported formats before kicking off the upload simulation. + // The native picker filters via the `accept` attribute, but drag-and-drop + // and some platforms ignore it, so this is the user-facing safety net. + if (!isFileAccepted(file, acceptedFormats)) { + setUploadedFile({ + id, + name: file.name, + size: file.size, + progress: 0, + status: 'unsupported-format', + errorMessage: unsupportedFormatMessage(acceptedFormats), + }); + onChange?.(null); + return; + } + // Check file size if (file.size > maxSize * 1024 * 1024) { setUploadedFile({ @@ -301,6 +322,53 @@ const FormFileUpload: React.FC = ({ ); + case 'unsupported-format': + return ( + +
+ +
+
+
+
+
+ +
+
+
+

+ {uploadedFile?.name || 'Selected file'} +

+

+ {uploadedFile?.errorMessage || + unsupportedFormatMessage(acceptedFormats)} +

+
+
+ +
+
+
+
+ ); + case 'failed': case 'error': return ( diff --git a/src/components/common/PercentageBadge.tsx b/src/components/common/PercentageBadge.tsx new file mode 100644 index 0000000..b0bbc3f --- /dev/null +++ b/src/components/common/PercentageBadge.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/lib/utils'; +import { formatPercent, type FormatPercentOptions } from '@/utils/numberFormat.utils'; + +export type PercentageBadgeTone = + | 'neutral' + | 'positive' + | 'negative' + | 'muted'; + +interface PercentageBadgeProps extends FormatPercentOptions { + /** Percentage value (already in percentage units — `12.5` renders `12.5%`). */ + value: number | null | undefined; + /** + * Visual tone. `neutral` (default) renders a quiet white chip suitable for + * informational badges; the other tones map to the color system used by + * `Change24hBadge`. + */ + tone?: PercentageBadgeTone; + /** Optional label rendered next to the value, e.g. `Funded`. */ + label?: string; + className?: string; +} + +const toneClasses: Record = { + neutral: 'border-white/10 bg-white/[0.06] text-white/70', + positive: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + negative: 'border-red-500/30 bg-red-500/10 text-red-400', + muted: 'border-white/10 bg-white/[0.04] text-white/40', +}; + +/** + * Standardized chip for percentage values. Uses the shared `formatPercent` + * helper so precision and edge-case behavior (null / NaN / Infinity) stay + * consistent across every percentage badge in the app. + */ +const PercentageBadge: React.FC = ({ + value, + tone = 'neutral', + label, + className, + ...formatOptions +}) => { + const formatted = formatPercent(value, formatOptions); + + return ( + + {label && {label}} + {formatted} + + ); +}; + +export default PercentageBadge; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 70b24e9..598254a 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -10,6 +10,7 @@ import { } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { formatNumber } from '@/utils/numberFormat.utils'; +import PercentageBadge from '@/components/common/PercentageBadge'; export type TradeSide = 'buy' | 'sell'; @@ -92,9 +93,23 @@ const TradeDialog: React.FC = ({ !amountValid && amountText.trim() ? 'border-red-500/40' : '' )} aria-label="Trade amount" + data-focus-order="1" + data-testid="trade-dialog-amount" /> -
- Holdings: {formatNumber(availableHoldings)} keys +
+ Holdings: {formatNumber(availableHoldings)} keys + {side === 'sell' && + availableHoldings > 0 && + Number.isFinite(parsedAmount) && + parsedAmount > 0 && ( + availableHoldings ? 'negative' : 'neutral' + } + /> + )}
{side === 'sell' && parsedAmount > availableHoldings && (
@@ -103,12 +118,23 @@ const TradeDialog: React.FC = ({ )}
+ {/* + * Focus order is intentional: amount input → Cancel → Confirm. + * That matches the visual left-to-right reading order in the + * footer (`sm:justify-between` puts Cancel on the left, Confirm + * on the right) and keeps the destructive action one Tab away + * from the primary action so users always pass through Cancel + * before reaching Confirm. The covering test in + * `__tests__/TradeDialog.focusOrder.test.tsx` guards this. + */} @@ -116,6 +142,8 @@ const TradeDialog: React.FC = ({ type="button" onClick={() => onConfirm(parsedAmount)} disabled={!amountValid || isSubmitting} + data-focus-order="3" + data-testid="trade-dialog-confirm" > {isSubmitting ? 'Submitting…' : confirmLabel} diff --git a/src/components/common/__tests__/CreatorBio.test.tsx b/src/components/common/__tests__/CreatorBio.test.tsx new file mode 100644 index 0000000..434dbdc --- /dev/null +++ b/src/components/common/__tests__/CreatorBio.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import CreatorBio from '@/components/common/CreatorBio'; + +describe('CreatorBio', () => { + it('renders the bio text when provided', () => { + render(); + expect( + screen.getByText('Building things on Stellar.') + ).toBeInTheDocument(); + }); + + it('falls back to the default helper text when the bio is missing', () => { + render(); + expect( + screen.getByText("This creator hasn't shared a bio yet.") + ).toBeInTheDocument(); + }); + + it('treats whitespace-only strings as missing', () => { + render(); + expect( + screen.getByText("This creator hasn't shared a bio yet.") + ).toBeInTheDocument(); + }); + + it('respects an explicit fallback override', () => { + render(); + expect(screen.getByText('No story shared yet.')).toBeInTheDocument(); + }); + + it('marks the fallback for assistive technology', () => { + render(); + expect( + screen.getByLabelText('Bio not provided') + ).toBeInTheDocument(); + }); + + it('trims surrounding whitespace from a real bio before rendering', () => { + render(); + expect(screen.getByText('real bio')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx new file mode 100644 index 0000000..e139913 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.focusOrder.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; + +/** + * The dialog's focus order is part of its accessibility contract — keyboard + * users tab through `amount input → Cancel → Confirm`, matching the + * left-to-right visual order. These tests guard against future refactors + * that accidentally swap Cancel and Confirm in the DOM (which would also + * swap them in the tab sequence) or remove the marker attributes. + */ +describe('TradeDialog focus order', () => { + function renderDialog(overrides: Partial> = {}) { + return render( + + ); + } + + it('renders the focus-order markers on the three primary controls', () => { + renderDialog(); + + expect(screen.getByTestId('trade-dialog-amount')).toHaveAttribute( + 'data-focus-order', + '1' + ); + expect(screen.getByTestId('trade-dialog-cancel')).toHaveAttribute( + 'data-focus-order', + '2' + ); + expect(screen.getByTestId('trade-dialog-confirm')).toHaveAttribute( + 'data-focus-order', + '3' + ); + }); + + it('orders the controls in DOM as amount → Cancel → Confirm so tab sequence matches', () => { + renderDialog(); + + const ordered = Array.from( + document.querySelectorAll('[data-focus-order]') + ).map(el => ({ + testId: el.getAttribute('data-testid'), + order: el.getAttribute('data-focus-order'), + })); + + expect(ordered).toEqual([ + { testId: 'trade-dialog-amount', order: '1' }, + { testId: 'trade-dialog-cancel', order: '2' }, + { testId: 'trade-dialog-confirm', order: '3' }, + ]); + }); + + it('keeps the primary action reachable (not removed from the tab sequence)', () => { + renderDialog(); + + const confirm = screen.getByTestId('trade-dialog-confirm'); + // A button with no explicit tabindex is in the tab sequence as long as + // it isn't disabled. This regression-tests that we never accidentally + // add `tabIndex={-1}` to the primary action. + expect(confirm.getAttribute('tabindex')).not.toBe('-1'); + }); + + it('keeps Cancel reachable so users can always back out via the keyboard', () => { + renderDialog(); + + const cancel = screen.getByTestId('trade-dialog-cancel'); + expect(cancel.getAttribute('tabindex')).not.toBe('-1'); + }); + + it('preserves the same focus order in the sell variant', () => { + renderDialog({ side: 'sell' }); + + const ordered = Array.from( + document.querySelectorAll('[data-focus-order]') + ).map(el => el.getAttribute('data-focus-order')); + + expect(ordered).toEqual(['1', '2', '3']); + }); +}); diff --git a/src/utils/__tests__/fileFormat.utils.test.ts b/src/utils/__tests__/fileFormat.utils.test.ts new file mode 100644 index 0000000..d4b3d4b --- /dev/null +++ b/src/utils/__tests__/fileFormat.utils.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + isFileAccepted, + unsupportedFormatMessage, +} from '@/utils/fileFormat.utils'; + +function makeFile(name: string, type: string): File { + return new File([new Uint8Array([0])], name, { type }); +} + +describe('isFileAccepted', () => { + it('matches by extension token', () => { + expect(isFileAccepted(makeFile('avatar.png', 'image/png'), '.png')).toBe( + true + ); + expect( + isFileAccepted(makeFile('avatar.PNG', 'image/png'), '.png') + ).toBe(true); + }); + + it('rejects extensions that are not in the accept list', () => { + expect(isFileAccepted(makeFile('avatar.bmp', 'image/bmp'), '.png,.jpg')).toBe( + false + ); + }); + + it('matches by exact MIME type', () => { + expect( + isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/png') + ).toBe(true); + expect( + isFileAccepted( + makeFile('avatar.png', 'image/png'), + 'image/jpeg' + ) + ).toBe(false); + }); + + it('honors the image/* wildcard', () => { + expect( + isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/*') + ).toBe(true); + expect( + isFileAccepted(makeFile('doc.pdf', 'application/pdf'), 'image/*') + ).toBe(false); + }); + + it('treats an empty accept list as accepting any file', () => { + expect(isFileAccepted(makeFile('anything', ''), '')).toBe(true); + }); + + it('handles whitespace and mixed case in the accept list', () => { + expect( + isFileAccepted( + makeFile('avatar.png', 'image/png'), + ' .JPG, .PNG ' + ) + ).toBe(true); + }); +}); + +describe('unsupportedFormatMessage', () => { + it('lists allowed formats in upper case without leading dots', () => { + expect(unsupportedFormatMessage('.png,.jpg')).toBe( + "That file format isn't supported. Use PNG, JPG." + ); + }); + + it('falls back to generic copy when accept is empty', () => { + expect(unsupportedFormatMessage('')).toBe( + "That file format isn't supported. Pick a different file." + ); + }); + + it('preserves wildcards as-is so MIME ranges read sensibly', () => { + expect(unsupportedFormatMessage('image/*')).toBe( + "That file format isn't supported. Use IMAGE/*." + ); + }); +}); diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts new file mode 100644 index 0000000..b9564ab --- /dev/null +++ b/src/utils/__tests__/numberFormat.utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { formatPercent } from '@/utils/numberFormat.utils'; + +describe('formatPercent', () => { + it('renders the value with a trailing percent sign', () => { + expect(formatPercent(12.5)).toBe('12.5%'); + }); + + it('rounds to two decimal places by default', () => { + expect(formatPercent(12.345)).toBe('12.35%'); + expect(formatPercent(0.001)).toBe('0%'); + }); + + it('renders zero cleanly without spurious decimals', () => { + expect(formatPercent(0)).toBe('0%'); + }); + + it('handles small decimals without scientific notation', () => { + expect(formatPercent(0.05)).toBe('0.05%'); + expect(formatPercent(0.0001)).toBe('0%'); + }); + + it('handles large values without truncation', () => { + expect(formatPercent(123456)).toMatch(/123,?456%/); + }); + + it('returns the placeholder for null, undefined, NaN, and Infinity', () => { + expect(formatPercent(null)).toBe('—'); + expect(formatPercent(undefined)).toBe('—'); + expect(formatPercent(Number.NaN)).toBe('—'); + expect(formatPercent(Number.POSITIVE_INFINITY)).toBe('—'); + expect(formatPercent(Number.NEGATIVE_INFINITY)).toBe('—'); + }); + + it('respects a custom placeholder', () => { + expect(formatPercent(null, { emptyPlaceholder: 'no data' })).toBe( + 'no data' + ); + }); + + it('prefixes positive values with + when signed=true', () => { + expect(formatPercent(12.5, { signed: true })).toBe('+12.5%'); + expect(formatPercent(-12.5, { signed: true })).toBe('-12.5%'); + expect(formatPercent(0, { signed: true })).toBe('0%'); + }); + + it('respects custom precision options', () => { + expect( + formatPercent(12.345, { + maximumFractionDigits: 1, + minimumFractionDigits: 1, + }) + ).toBe('12.3%'); + expect( + formatPercent(12, { + minimumFractionDigits: 2, + }) + ).toBe('12.00%'); + }); +}); diff --git a/src/utils/fileFormat.utils.ts b/src/utils/fileFormat.utils.ts new file mode 100644 index 0000000..c0b9d60 --- /dev/null +++ b/src/utils/fileFormat.utils.ts @@ -0,0 +1,40 @@ +/** + * Returns true when `file` matches the comma-separated `accept` string used + * by ``. Mirrors browser semantics: each + * token is an extension (`.png`), a MIME type (`image/png`), or a wildcard + * (`image/*`). An empty `accept` accepts everything. + */ +export function isFileAccepted(file: File, accept: string): boolean { + const tokens = accept + .split(',') + .map(token => token.trim().toLowerCase()) + .filter(Boolean); + if (tokens.length === 0) return true; + + const fileType = file.type.toLowerCase(); + const fileName = file.name.toLowerCase(); + + return tokens.some(token => { + if (token.startsWith('.')) { + return fileName.endsWith(token); + } + if (token.endsWith('/*')) { + return fileType.startsWith(token.slice(0, -1)); + } + return fileType === token; + }); +} + +/** User-facing helper text shown when a file's format is rejected. */ +export function unsupportedFormatMessage(accept: string): string { + const formats = accept + .split(',') + .map(token => token.trim()) + .filter(Boolean) + .map(token => (token.startsWith('.') ? token.slice(1) : token)) + .map(token => token.toUpperCase()); + if (formats.length === 0) { + return "That file format isn't supported. Pick a different file."; + } + return `That file format isn't supported. Use ${formats.join(', ')}.`; +} diff --git a/src/utils/numberFormat.utils.ts b/src/utils/numberFormat.utils.ts index d4b5723..53a6ad8 100644 --- a/src/utils/numberFormat.utils.ts +++ b/src/utils/numberFormat.utils.ts @@ -39,3 +39,50 @@ export function formatCompactNumber( return formatNumber(value, { ...options, style: 'compact' }); } +export interface FormatPercentOptions { + /** Maximum fractional digits in the rendered value. Defaults to 2. */ + maximumFractionDigits?: number; + /** Minimum fractional digits. Defaults to 0 so whole-number values render cleanly. */ + minimumFractionDigits?: number; + /** + * Prefix positive values with `+` so badges read `+12.5%` / `-3.4%`. + * Defaults to false. + */ + signed?: boolean; + /** Placeholder rendered when the value is missing or non-finite. Defaults to `—`. */ + emptyPlaceholder?: string; +} + +/** + * Formats a percentage value for badges and chips with consistent precision. + * + * Treats the input as a percentage (i.e. `12.5` renders as `12.5%`, not `1250%`). + * Edge cases are stable: `null`, `undefined`, `NaN`, and `Infinity` all render + * as the placeholder; values smaller than the requested precision are rounded + * (the previous hand-rolled `toFixed(2)` behavior) so badges never show + * scientific notation. + */ +export function formatPercent( + value: number | null | undefined, + options: FormatPercentOptions = {} +): string { + const { + maximumFractionDigits = 2, + minimumFractionDigits = 0, + signed = false, + emptyPlaceholder = '—', + } = options; + + if (value == null || !Number.isFinite(value)) { + return emptyPlaceholder; + } + + const formatted = new Intl.NumberFormat(undefined, { + maximumFractionDigits, + minimumFractionDigits, + }).format(value); + + const sign = signed && value > 0 ? '+' : ''; + return `${sign}${formatted}%`; +} +