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
138 changes: 67 additions & 71 deletions src/components/common/PortfolioHoldingRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import { Button } from '@/components/ui/button';
import LockupCountdown from '@/components/common/LockupCountdown';
import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog';
import DeprecationNotice from '@/components/common/DeprecationNotice';

Check failure on line 5 in src/components/common/PortfolioHoldingRow.tsx

View workflow job for this annotation

GitHub Actions / verify

'DeprecationNotice' is defined but never used
import RedeemKeyDialog from '@/components/common/RedeemKeyDialog';
import { computeRemainingLockupSeconds } from '@/utils/lockupCountdown.utils';
import { formatNumber } from '@/utils/numberFormat.utils';
import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils';
import { hasUnclaimedDividend, xlmToStroops } from '@/utils/reinvestDividend.utils';

Check failure on line 10 in src/components/common/PortfolioHoldingRow.tsx

View workflow job for this annotation

GitHub Actions / verify

'hasUnclaimedDividend' is defined but never used
import { isKeyDeprecated } from '@/utils/keyDeprecation.utils';

Check failure on line 11 in src/components/common/PortfolioHoldingRow.tsx

View workflow job for this annotation

GitHub Actions / verify

'isKeyDeprecated' is defined but never used
import { TrendingUp } from 'lucide-react';
import type { HeldKeyPosition } from '@/utils/portfolioValue.utils';
import type { Course } from '@/services/course.service';
Expand All @@ -19,8 +19,10 @@
creator?: Course;
onBuy?: (creatorId: string) => void;
onSell?: (creatorId: string) => void;
onReinvest?: (creatorId: string) => Promise<void> | void;
onRedeem?: (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;
isRedeeming?: boolean;
Expand All @@ -32,27 +34,21 @@
creator,
onBuy,
onSell,
onReinvest,
onRedeem,
onFreeze,
onUnfreeze,
onTransfer,
onBurn,
isSubmitting = false,
isReinvesting = false,
isRedeeming = false,
isNetworkMismatch = false,
}) => {
const initialRemaining = computeRemainingLockupSeconds(position.last_buy_timestamp);
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
const [reinvestOpen, setReinvestOpen] = useState(false);
const [redeemOpen, setRedeemOpen] = useState(false);

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

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;

const handleConfirmRedeem = async () => {
if (!onRedeem) return;
Expand All @@ -70,7 +66,7 @@
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 @@ -80,10 +76,7 @@
Pending
</span>
)}
{deprecated && (
<DeprecationNotice reason={creator?.deprecationReason} />
)}
</div>
</button>
<div className="mt-1 text-xs text-white/55">
{formatNumber(position.quantity)} keys ·{' '}
{position.isPriceLoading
Expand Down Expand Up @@ -115,59 +108,62 @@
)}

<div className="flex items-center gap-2">
{deprecated ? (
onRedeem && (
<Button
size="sm"
className="rounded-xl"
onClick={() => setRedeemOpen(true)}
disabled={isNetworkMismatch || isSubmitting || isRedeeming}
data-testid="holding-redeem-button"
>
Redeem
</Button>
)
) : (
<>
{onReinvest && hasDividends && (
<Button
size="sm"
variant="outline"
className="rounded-xl"
onClick={() => setReinvestOpen(true)}
disabled={isNetworkMismatch || isSubmitting || isReinvesting}
data-testid="holding-reinvest-button"
>
Reinvest
</Button>
)}
{onBuy && (
<Button
size="sm"
className="rounded-xl"
onClick={() => onBuy(position.creatorId)}
disabled={isNetworkMismatch || isSubmitting}
data-testid="holding-buy-button"
>
Buy
</Button>
)}
{onSell && (
<Button
size="sm"
variant="outline"
className="rounded-xl"
onClick={() => onSell(position.creatorId)}
disabled={isLocked || isNetworkMismatch || isSubmitting}
data-testid="holding-sell-button"
>
Sell
</Button>
)}
</>
{onReinvest && hasDividends && (
<Button
size="sm"
variant="outline"
className="rounded-xl"
onClick={() => setReinvestOpen(true)}
disabled={isNetworkMismatch || isSubmitting || isReinvesting}
data-testid="holding-reinvest-button"
>
Reinvest
</Button>
)}
{onBuy && (
<Button
size="sm"
className="rounded-xl"
onClick={() => onBuy(position.creatorId)}
disabled={isNetworkMismatch || isSubmitting}
data-testid="holding-buy-button"
>
Buy
</Button>
)}
{onSell && (
<Button
size="sm"
variant="outline"
className="rounded-xl"
onClick={() => onSell(position.creatorId)}
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
Loading