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
106 changes: 106 additions & 0 deletions src/components/common/ExportTradeHistoryCsvButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
import { useAccount } from 'wagmi';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { downloadCsv } from '@/utils/transactionHistory.utils';
import {
buildTradeHistoryCsvFilename,
generateTradeHistoryCsv,
} from '@/utils/tradeLedger.utils';
import { fetchAllTrades } from '@/hooks/useAllTradeHistory';
import type { Trade } from '@/services/tradeHistory.service';

export interface ExportTradeHistoryCsvButtonProps {
/** Wallet address whose trades to export. */
walletAddress: string;
/** Optional authenticated override (e.g. for testing). */
isAuthenticated?: boolean;
/** Whether the viewer is viewing their own profile dashboard. */
isOwnProfile?: boolean;
/** Optional custom trade fetcher (e.g. for testing). */
fetchTrades?: (wallet: string) => Promise<Trade[]>;
/** Custom class name. */
className?: string;
/** Custom test ID. */
'data-testid'?: string;
}

/**
* Trade history CSV export button on the portfolio page (#857).
*
* Placed in the trade history tab header. When clicked by an authenticated
* user, fetches all trades from GET /users/:wallet/trades without pagination
* limit, displays a loading spinner on the button, generates a formatted CSV
* (columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee), and
* triggers a file download with filename trades-[wallet-truncated]-[date].csv.
* Only accessible when the user is authenticated.
*/
export const ExportTradeHistoryCsvButton: React.FC<
ExportTradeHistoryCsvButtonProps
> = ({
walletAddress,
isAuthenticated: customIsAuthenticated,
isOwnProfile = false,
fetchTrades = fetchAllTrades,
className,
'data-testid': testId = 'export-trade-history-button',
}) => {
const { address: connectedAddress, isConnected } = useAccount();
const [isLoading, setIsLoading] = useState(false);

const isAuth =
customIsAuthenticated !== undefined
? customIsAuthenticated
: Boolean(connectedAddress && (isConnected ?? true)) ||
Boolean(isOwnProfile);

// Button accessible only when the user is authenticated (#857)
if (!isAuth) {
return null;
}

const handleExport = async () => {
if (isLoading || !walletAddress) return;
setIsLoading(true);

try {
const trades = await fetchTrades(walletAddress);
const csv = generateTradeHistoryCsv(trades);
const filename = buildTradeHistoryCsvFilename(walletAddress);
downloadCsv(filename, csv);
} catch (error) {
console.error('Failed to export trade history CSV:', error);
} finally {
setIsLoading(false);
}
};

return (
<Button
type="button"
variant="outline"
onClick={handleExport}
disabled={isLoading}
data-testid={testId}
aria-busy={isLoading}
className={cn(
'gap-2 rounded-xl border-white/10 bg-white/[0.04] text-sm font-semibold text-white hover:bg-white/[0.08] hover:text-white',
className
)}
>
{isLoading ? (
<Loader2
className="size-4 animate-spin"
data-testid="export-trade-history-spinner"
aria-hidden="true"
/>
) : (
<Download className="size-4" aria-hidden="true" />
)}
Export CSV
</Button>
);
};

export default ExportTradeHistoryCsvButton;
25 changes: 19 additions & 6 deletions src/components/common/HeldKeysGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
} from '@/utils/portfolioValue.utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { isKeyDeprecated } from '@/utils/keyDeprecation.utils';
import { isSplitActive } from '@/utils/keySplit.utils';
import { keySplitEventService } from '@/services/keySplitEvents.service';
import SplitAppliedBadge from '@/components/common/SplitAppliedBadge';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';

Expand Down Expand Up @@ -80,6 +83,11 @@ const HeldKeysGrid: React.FC<HeldKeysGridProps> = ({
const title = creator?.title ?? 'Creator key';
const quantity = position.quantity ?? 0;
const isDeprecated = isKeyDeprecated(creator);
const hasSplitApplied = Boolean(
isSplitActive(creator?.recentSplit) ||
isSplitActive(creator?.splitExecuted) ||
keySplitEventService.getActiveSplit(position.creatorId)
);
const creatorHref = `/creator/${position.creatorId}`;

return (
Expand All @@ -89,12 +97,17 @@ const HeldKeysGrid: React.FC<HeldKeysGridProps> = ({
className="flex flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-colors hover:border-white/20"
>
<div className="mb-3 flex items-start justify-between gap-3">
<Link
to={creatorHref}
className="min-w-0 truncate font-grotesque text-base font-bold text-white transition-colors hover:text-amber-300"
>
{title}
</Link>
<div className="flex flex-wrap items-center gap-2 min-w-0">
<Link
to={creatorHref}
className="min-w-0 truncate font-grotesque text-base font-bold text-white transition-colors hover:text-amber-300"
>
{title}
</Link>
{hasSplitApplied && (
<SplitAppliedBadge />
)}
</div>
{position.pending && (
<span
role="status"
Expand Down
191 changes: 191 additions & 0 deletions src/components/common/KeySplitBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { Scissors, X, CheckCircle2 } from 'lucide-react';
import type { Course } from '@/services/course.service';
import type { SplitExecutedEvent } from '@/types/keySplit';
import { useKeySplit } from '@/hooks/useKeySplit';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { formatNumber } from '@/utils/numberFormat.utils';

export interface KeySplitBannerProps {
/** Creator key identifier. */
keyId?: string;
/** Creator course object carrying key details and optional recentSplit. */
creator?: Course | null;
/** Connected wallet address. */
userAddress?: string;
/** Direct split event instance. */
split?: SplitExecutedEvent | null;
/** Explicit override for pre-split balance. */
preSplitBalance?: number | null;
/** Explicit override for post-split balance. */
postSplitBalance?: number | null;
/** Explicit override for split ratio. */
ratio?: number | null;
/** Number of keys held by the connected user. */
holdingsCount?: number | null;
/** Callback when the banner is dismissed. */
onDismiss?: () => void;
className?: string;
}

/**
* Key split event notification banner (#1151).
*
* Appears for 48 hours after a SplitExecuted event on key detail and portfolio pages.
* Informs holders of their adjusted balance and the split ratio applied.
* Includes a Dismiss button that clears the banner for the session.
*/
export const KeySplitBanner: React.FC<KeySplitBannerProps> = ({
keyId: explicitKeyId,
creator,
userAddress,
split: explicitSplit,
preSplitBalance: overridePreSplitBalance,
postSplitBalance: overridePostSplitBalance,
ratio: overrideRatio,
holdingsCount,
onDismiss,
className,
}) => {
const effectiveKeyId = explicitKeyId ?? creator?.id ?? explicitSplit?.keyId ?? '';
const initialSplit = explicitSplit ?? creator?.recentSplit ?? creator?.splitExecuted ?? null;

const {
split,
isActive,
isDismissed: hookDismissed,
ratioDisplay,
ratio: hookRatio,
preSplitBalance: hookPreSplitBalance,
postSplitBalance: hookPostSplitBalance,
dismiss: hookDismiss,
} = useKeySplit({
keyId: effectiveKeyId,
userAddress,
initialSplit,
holdingQuantity: holdingsCount,
});

const [localDismissed, setLocalDismissed] = useState(false);

const isDismissed = localDismissed || hookDismissed;

if (!isActive || isDismissed) {
return null;
}

const displayRatio = overrideRatio != null ? `${overrideRatio}:1` : ratioDisplay;
const numericRatio = overrideRatio ?? split?.ratio ?? hookRatio;

const finalPreSplitBalance = overridePreSplitBalance ?? hookPreSplitBalance;
const finalPostSplitBalance =
overridePostSplitBalance ??
(overridePreSplitBalance != null && numericRatio > 0
? overridePreSplitBalance * numericRatio
: hookPostSplitBalance);

const handleDismiss = () => {
setLocalDismissed(true);
hookDismiss();
onDismiss?.();
};

const keyTitle = creator?.title ? ` for ${creator.title}` : '';

return (
<aside
role="alert"
aria-label="Key split notification"
data-testid="key-split-banner"
className={cn(
'relative overflow-hidden rounded-2xl border border-purple-500/35 bg-gradient-to-r from-purple-950/40 via-purple-900/20 to-slate-900/60 p-5 backdrop-blur-md shadow-xl shadow-purple-950/25 md:p-6',
className
)}
>
<div className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
{/* Left: Info & Description */}
<div className="space-y-2 max-w-2xl">
<div className="flex flex-wrap items-center gap-2.5">
<span className="inline-flex items-center gap-1.5 rounded-full border border-purple-400/40 bg-purple-500/20 px-3 py-0.5 text-xs font-semibold text-purple-300">
<Scissors className="size-3.5" aria-hidden="true" />
<span>Key Split Executed</span>
</span>
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-purple-200/90"
data-testid="split-ratio"
>
Ratio: {displayRatio} ({numericRatio}x)
</span>
</div>

<p className="text-sm text-white/85 leading-relaxed font-jakarta">
A key split{keyTitle} has been executed. Holders have had their balances
adjusted according to the {displayRatio} split ratio.
</p>

<div className="flex items-center gap-2 text-xs font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-lg w-fit">
<CheckCircle2 className="size-3.5 shrink-0" aria-hidden="true" />
<span>Balances updated and live</span>
</div>
</div>

{/* Middle: Pre-split and Post-split balances */}
<div className="flex flex-wrap items-center gap-3">
{/* Pre-split balance */}
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.04] px-4 py-3 min-w-[130px]">
<div>
<div className="text-[0.68rem] uppercase tracking-wider text-white/50 font-medium">
Pre-Split Balance
</div>
<div
className="text-base font-bold text-white/80 tabular-nums"
data-testid="pre-split-balance"
>
{formatNumber(finalPreSplitBalance)} keys
</div>
</div>
</div>

{/* Arrow separator */}
<div className="text-purple-400 font-bold text-lg hidden sm:block" aria-hidden="true">
→
</div>

{/* Post-split balance */}
<div className="flex items-center gap-3 rounded-xl border border-purple-500/30 bg-purple-500/10 px-4 py-3 min-w-[130px]">
<div>
<div className="text-[0.68rem] uppercase tracking-wider text-purple-300 font-medium">
Post-Split Balance
</div>
<div
className="text-base font-bold text-purple-200 tabular-nums"
data-testid="post-split-balance"
>
{formatNumber(finalPostSplitBalance)} keys
</div>
</div>
</div>
</div>

{/* Right: Dismiss button */}
<div className="flex items-center justify-end">
<Button
type="button"
variant="outline"
onClick={handleDismiss}
data-testid="split-banner-dismiss"
aria-label="Dismiss key split notification for session"
className="gap-1.5 rounded-xl border-white/15 bg-white/5 text-xs font-semibold text-white/80 hover:bg-white/10 hover:text-white"
>
<X className="size-3.5" aria-hidden="true" />
<span>Dismiss</span>
</Button>
</div>
</div>
</aside>
);
};

export const SplitNotificationBanner = KeySplitBanner;
export default KeySplitBanner;
33 changes: 33 additions & 0 deletions src/components/common/SplitAppliedBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';
import { Scissors } from 'lucide-react';
import { cn } from '@/lib/utils';

export interface SplitAppliedBadgeProps {
className?: string;
title?: string;
}

/**
* Split Applied badge rendered on portfolio pages for keys that split in the last 48h (#1151).
*/
export const SplitAppliedBadge: React.FC<SplitAppliedBadgeProps> = ({
className,
title = 'Key split applied within the last 48 hours',
}) => {
return (
<span
role="status"
data-testid="split-applied-badge"
title={title}
className={cn(
'inline-flex items-center gap-1 rounded-full border border-purple-500/40 bg-purple-500/15 px-2.5 py-0.5 text-xs font-semibold text-purple-300 shadow-sm shadow-purple-950/20 backdrop-blur-xs',
className
)}
>
<Scissors className="size-3" aria-hidden="true" />
<span>Split Applied</span>
</span>
);
};

export default SplitAppliedBadge;
Loading
Loading