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
10 changes: 4 additions & 6 deletions src/components/common/Change24hBadge.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -10,6 +11,7 @@ interface Change24hBadgeProps {
const Change24hBadge: React.FC<Change24hBadgeProps> = ({ change, className }) => {
const isPositive = change !== undefined && change > 0;
const isNegative = change !== undefined && change < 0;
const formatted = formatPercent(change, { signed: true });

return (
<div
Expand All @@ -20,16 +22,12 @@ const Change24hBadge: React.FC<Change24hBadgeProps> = ({ 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 && <TrendingUp className="size-3" />}
{isNegative && <TrendingDown className="size-3" />}
{!isPositive && !isNegative && <Minus className="size-3" />}
<span>
{change !== undefined
? `${change > 0 ? '+' : ''}${change.toFixed(2)}%`
: '—'}
</span>
<span>{formatted}</span>
</div>
);
};
Expand Down
52 changes: 52 additions & 0 deletions src/components/common/CreatorBio.tsx
Original file line number Diff line number Diff line change
@@ -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<CreatorBioProps> = ({
bio,
fallback = DEFAULT_FALLBACK,
variant = 'card',
className,
}) => {
const trimmed = bio?.trim();
const styles = variantClasses[variant];

if (!trimmed) {
return (
<p className={cn(styles.fallback, className)} aria-label="Bio not provided">
{fallback}
</p>
);
}

return <p className={cn(styles.value, className)}>{trimmed}</p>;
};

export default CreatorBio;
3 changes: 3 additions & 0 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -151,6 +152,8 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
@{creator.instructorId || 'creator'}
</p>

<CreatorBio bio={creator.description} variant="card" className="mt-2" />

{creator.socialHandle ? (
<div className="mt-2 flex items-center gap-1.5 text-xs text-white/60">
<LinkIcon className="size-3 text-amber-500/70" />
Expand Down
4 changes: 4 additions & 0 deletions src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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;
handle: string;
creatorId?: string | number | null;
avatarUrl?: string;
isVerified?: boolean;
bio?: string | null;
className?: string;
}

Expand All @@ -21,6 +23,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
creatorId,
avatarUrl,
isVerified,
bio,
className,
}) => {
const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -75,6 +78,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
{isVerified && <VerifiedBadge verified={true} />}
</div>
<p className="font-jakarta text-lg text-white/50">@{handle}</p>
<CreatorBio bio={bio} variant="profile" className="mt-2 max-w-md" />
</div>
</div>

Expand Down
68 changes: 68 additions & 0 deletions src/components/common/FormFileUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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'
| 'uploading'
| 'success'
| 'error'
| 'too-large'
| 'unsupported-format'
| 'failed';

// Configuration props for the component
Expand Down Expand Up @@ -65,6 +70,22 @@ const FormFileUpload: React.FC<FileUploadProps> = ({
}, [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({
Expand Down Expand Up @@ -301,6 +322,53 @@ const FormFileUpload: React.FC<FileUploadProps> = ({
</Fragment>
);

case 'unsupported-format':
return (
<Fragment>
<div className="relative bg-white">
<input
type="file"
accept={acceptedFormats}
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
id={id}
/>
<div className="border-2 border-dashed border-yellow-300 rounded-lg p-5 bg-yellow-50/40">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="p-3 bg-yellow-100 rounded-full flex items-center justify-center">
<div className="p-1 bg-yellow-400 rounded-full flex items-center justify-center">
<Alert className="w-3 h-3 text-white stroke-3" />
</div>
</div>
<div>
<p className="text-md font-medium text-gray-700 font-manrope mb-1">
{uploadedFile?.name || 'Selected file'}
</p>
<p
className="text-xs text-yellow-700"
role="alert"
data-testid="unsupported-format-note"
>
{uploadedFile?.errorMessage ||
unsupportedFormatMessage(acceptedFormats)}
</p>
</div>
</div>
<button
type="button"
className="p-2 bg-zinc-100 text-zinc-500 text-sm font-medium rounded-sm hover:bg-zinc-200 transition-colors font-inter flex items-center justify-center border"
onClick={handleRemove}
aria-label="Remove unsupported file"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
</Fragment>
);

case 'failed':
case 'error':
return (
Expand Down
60 changes: 60 additions & 0 deletions src/components/common/PercentageBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<PercentageBadgeTone, string> = {
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<PercentageBadgeProps> = ({
value,
tone = 'neutral',
label,
className,
...formatOptions
}) => {
const formatted = formatPercent(value, formatOptions);

return (
<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',
toneClasses[tone],
className
)}
title={label ? `${label}: ${formatted}` : formatted}
>
{label && <span className="text-white/50">{label}</span>}
<span>{formatted}</span>
</span>
);
};

export default PercentageBadge;
32 changes: 30 additions & 2 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -92,9 +93,23 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
!amountValid && amountText.trim() ? 'border-red-500/40' : ''
)}
aria-label="Trade amount"
data-focus-order="1"
data-testid="trade-dialog-amount"
/>
<div className="text-xs text-white/45">
Holdings: {formatNumber(availableHoldings)} keys
<div className="flex flex-wrap items-center gap-2 text-xs text-white/45">
<span>Holdings: {formatNumber(availableHoldings)} keys</span>
{side === 'sell' &&
availableHoldings > 0 &&
Number.isFinite(parsedAmount) &&
parsedAmount > 0 && (
<PercentageBadge
label="of holdings"
value={(parsedAmount / availableHoldings) * 100}
tone={
parsedAmount > availableHoldings ? 'negative' : 'neutral'
}
/>
)}
</div>
{side === 'sell' && parsedAmount > availableHoldings && (
<div className="text-xs text-red-300">
Expand All @@ -103,19 +118,32 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
)}
</div>

{/*
* 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.
*/}
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
data-focus-order="2"
data-testid="trade-dialog-cancel"
>
Cancel
</Button>
<Button
type="button"
onClick={() => onConfirm(parsedAmount)}
disabled={!amountValid || isSubmitting}
data-focus-order="3"
data-testid="trade-dialog-confirm"
>
{isSubmitting ? 'Submitting…' : confirmLabel}
</Button>
Expand Down
Loading
Loading