Skip to content
Open
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
49 changes: 34 additions & 15 deletions src/components/common/PortfolioHoldingRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export interface PortfolioHoldingRowProps {
creator?: Course;
onBuy?: (creatorId: string) => void;
onSell?: (creatorId: string) => void;
onReinvest?: (creatorId: string) => Promise<void> | void;
onFreeze?: (position: HeldKeyPosition) => void;
onUnfreeze?: (position: HeldKeyPosition) => void;
onTransfer?: (creatorId: string) => void;
onBurn?: (creatorId: string) => void;
isSubmitting?: boolean;
isReinvesting?: boolean;
isNetworkMismatch?: boolean;
Expand All @@ -27,23 +30,20 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
creator,
onBuy,
onSell,
onReinvest,
onFreeze,
onUnfreeze,
onTransfer,
onBurn,
isSubmitting = false,
isReinvesting = false,
isNetworkMismatch = false,
}) => {
const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp);
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
const [reinvestOpen, setReinvestOpen] = useState(false);

const hasDividends = hasUnclaimedDividend(position.unclaimedDividend);
const keyPriceStroops = resolveCreatorKeyPriceStroops(position);

const handleConfirmReinvest = async () => {
if (!onReinvest) return;
await onReinvest(position.creatorId);
setReinvestOpen(false);
};
const [isExpanded, setIsExpanded] = useState(false);
const frozenQuantity = position.frozenQuantity ?? 0;
const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0;
const isLiquidEmpty = liquidQuantity <= 0;

return (
<>
Expand All @@ -55,7 +55,7 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
data-testid="portfolio-holding-row"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<button type="button" className="flex items-center gap-2 text-left" onClick={() => setIsExpanded(value => !value)} aria-expanded={isExpanded} data-testid="holding-expand-button">
<span className="truncate text-sm font-bold text-white">
{creator?.title ?? 'Unknown creator'}
</span>
Expand All @@ -65,7 +65,7 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
Pending
</span>
)}
</div>
</button>
<div className="mt-1 text-xs text-white/55">
{formatNumber(position.quantity)} keys ·{' '}
{position.isPriceLoading
Expand Down Expand Up @@ -124,14 +124,33 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
variant="outline"
className="rounded-xl"
onClick={() => onSell(position.creatorId)}
disabled={isLocked || isNetworkMismatch || isSubmitting}
disabled={isLocked || isLiquidEmpty || isNetworkMismatch || isSubmitting}
data-testid="holding-sell-button"
>
Sell
</Button>
)}
</div>
</div>
{isExpanded && (
<div className="basis-full border-t border-white/10 pt-4" data-testid="self-freeze-section">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h3 className="text-sm font-bold text-white">Self-freeze</h3>
<dl className="mt-2 flex gap-5 text-xs text-white/55">
<div><dt>Frozen</dt><dd className="font-semibold text-white">{formatNumber(frozenQuantity)} keys</dd></div>
<div><dt>Liquid</dt><dd className="font-semibold text-white">{formatNumber(liquidQuantity)} keys</dd></div>
</dl>
</div>
<div className="flex flex-wrap gap-2">
{onFreeze && <Button size="sm" variant="outline" onClick={() => onFreeze(position)} disabled={isLiquidEmpty || isSubmitting} data-testid="holding-freeze-button">Freeze</Button>}
{onUnfreeze && <Button size="sm" variant="outline" onClick={() => onUnfreeze(position)} disabled={frozenQuantity <= 0 || isSubmitting} data-testid="holding-unfreeze-button">Unfreeze</Button>}
{onTransfer && <Button size="sm" variant="outline" onClick={() => onTransfer(position.creatorId)} disabled={isLiquidEmpty || isSubmitting} data-testid="holding-transfer-button">Transfer</Button>}
{onBurn && <Button size="sm" variant="outline" onClick={() => onBurn(position.creatorId)} disabled={isLiquidEmpty || isSubmitting} data-testid="holding-burn-button">Burn</Button>}
</div>
</div>
</div>
)}
</div>

{onReinvest && hasDividends && position.unclaimedDividend != null && (
Expand Down
114 changes: 114 additions & 0 deletions src/components/common/SelfFreezeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { StableButtonContent } from '@/components/ui/stable-button-content';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatNumber } from '@/utils/numberFormat.utils';
import type { SelfFreezeAction } from '@/hooks/useWallet';

interface SelfFreezeDialogProps {
open: boolean;
action: SelfFreezeAction;
creatorName: string;
availableQuantity: number;
isSubmitting?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (amount: number) => Promise<void> | void;
}

export default function SelfFreezeDialog({
open,
action,
creatorName,
availableQuantity,
isSubmitting = false,
onOpenChange,
onConfirm,
}: SelfFreezeDialogProps) {
const [amountText, setAmountText] = useState('1');
const [touched, setTouched] = useState(false);
const amountInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
if (open) {
setAmountText('1');
setTouched(false);
}
}, [open]);

const amount = Number(amountText);
const validationError = useMemo(() => {
if (!amountText.trim()) return 'Please enter an amount.';
if (!Number.isFinite(amount) || amount <= 0)
return 'Amount must be greater than zero.';
if (amount > availableQuantity)
return `You can't ${action} more than your available balance (${formatNumber(availableQuantity)} keys).`;
return null;
}, [action, amount, amountText, availableQuantity]);
const showError = touched && validationError !== null;
const label = action === 'freeze' ? 'Freeze' : 'Unfreeze';

return (
<Dialog open={open} onOpenChange={next => !isSubmitting && onOpenChange(next)}>
<DialogContent
className="max-w-md"
showCloseButton={!isSubmitting}
onOpenAutoFocus={event => {
event.preventDefault();
amountInputRef.current?.focus();
}}
>
<DialogHeader>
<DialogTitle>{label} keys</DialogTitle>
<DialogDescription>
{label} keys for {creatorName} so they {action === 'freeze' ? 'cannot be sold or transferred' : 'can be sold or transferred again'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="self-freeze-amount" className="text-sm text-white/70">
Amount
</label>
<input
ref={amountInputRef}
id="self-freeze-amount"
inputMode="decimal"
value={amountText}
onChange={event => {
setAmountText(event.target.value);
setTouched(true);
}}
disabled={isSubmitting}
className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15"
aria-label={`${label} quantity`}
aria-invalid={showError || undefined}
data-testid="self-freeze-amount"
/>
{showError && <p role="alert" className="text-xs text-red-300">{validationError}</p>}
<p className="text-xs text-white/45">Available: {formatNumber(availableQuantity)} keys</p>
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button
type="button"
onClick={() => onConfirm(amount)}
disabled={validationError !== null || isSubmitting}
aria-busy={isSubmitting || undefined}
data-testid="self-freeze-confirm"
>
<StableButtonContent isLoading={isSubmitting} loadingLabel="Submitting…">
{label}
</StableButtonContent>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
45 changes: 45 additions & 0 deletions src/components/common/StakingPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { formatCountdownTime } from '@/utils/lockupCountdown.utils';

export interface StakingPanelProps {
key_id: string | number;
unlock_ledger: number;
onClaim: (keyId: string | number) => void | Promise<void>;
}

const getRemainingSeconds = (unlockLedger: number): number =>
Math.max(0, Math.ceil((unlockLedger * 1000 - Date.now()) / 1000));

const StakingPanel: React.FC<StakingPanelProps> = ({ key_id, unlock_ledger, onClaim }) => {
const [remainingSeconds, setRemainingSeconds] = useState(() =>
getRemainingSeconds(unlock_ledger)
);

useEffect(() => {
const updateRemaining = () => setRemainingSeconds(getRemainingSeconds(unlock_ledger));
updateRemaining();

const intervalId = setInterval(updateRemaining, 1000);
return () => clearInterval(intervalId);
}, [unlock_ledger]);

const isLocked = remainingSeconds > 0;

return (
<section data-testid="staking-panel">
<span data-testid="staking-lock-countdown">
{formatCountdownTime(remainingSeconds)}
</span>
<Button
onClick={() => onClaim(key_id)}
disabled={isLocked}
data-testid="staking-claim-button"
>
Claim
</Button>
</section>
);
};

export default StakingPanel;
52 changes: 52 additions & 0 deletions src/components/common/__tests__/StakingPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import StakingPanel from '@/components/common/StakingPanel';

describe('StakingPanel (#815)', () => {
const now = 1_700_000_000_000;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});

afterEach(() => {
vi.useRealTimers();
});

it('disables Claim while unlock_ledger is in the future', () => {
render(<StakingPanel key_id="key-1" unlock_ledger={now / 1000 + 3661} onClaim={vi.fn()} />);

expect(screen.getByTestId('staking-claim-button')).toBeDisabled();
});

it('enables Claim when unlock_ledger has passed', () => {
render(<StakingPanel key_id="key-1" unlock_ledger={now / 1000 - 1} onClaim={vi.fn()} />);

expect(screen.getByTestId('staking-claim-button')).not.toBeDisabled();
});

it('displays the lock expiry countdown in HH:MM:SS format', () => {
render(<StakingPanel key_id="key-1" unlock_ledger={now / 1000 + 3661} onClaim={vi.fn()} />);

expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('01:01:01');
});

it('enables Claim automatically when the countdown reaches zero', () => {
render(<StakingPanel key_id="key-1" unlock_ledger={now / 1000 + 2} onClaim={vi.fn()} />);

act(() => vi.advanceTimersByTime(2000));

expect(screen.getByTestId('staking-lock-countdown')).toHaveTextContent('00:00:00');
expect(screen.getByTestId('staking-claim-button')).not.toBeDisabled();
});

it('calls the contract boundary with the correct key_id on Claim', () => {
const onClaim = vi.fn();
render(<StakingPanel key_id={42} unlock_ledger={now / 1000 - 1} onClaim={onClaim} />);

fireEvent.click(screen.getByTestId('staking-claim-button'));

expect(onClaim).toHaveBeenCalledWith(42);
});
});
Loading