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
42 changes: 35 additions & 7 deletions src/components/common/PortfolioHoldingRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export interface PortfolioHoldingRowProps {
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 @@ -34,6 +38,10 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
onSell,
onReinvest,
onRedeem,
onFreeze,
onUnfreeze,
onTransfer,
onBurn,
isSubmitting = false,
isReinvesting = false,
isRedeeming = false,
Expand All @@ -43,7 +51,10 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
const [isLocked, setIsLocked] = useState(initialRemaining > 0);
const [reinvestOpen, setReinvestOpen] = useState(false);
const [redeemOpen, setRedeemOpen] = useState(false);

const [isExpanded, setIsExpanded] = useState(false);
const frozenQuantity = position.frozenQuantity ?? 0;
const liquidQuantity = position.liquidQuantity ?? position.quantity ?? 0;
const isLiquidEmpty = liquidQuantity <= 0;
const hasDividends = hasUnclaimedDividend(position.unclaimedDividend);
const keyPriceStroops = resolveCreatorKeyPriceStroops(position);
const deprecated = isKeyDeprecated(creator);
Expand All @@ -70,20 +81,18 @@ 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>
{deprecated && <DeprecationNotice reason={creator?.deprecationReason} />}
{position.pending && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-400">
<span className="size-2.5 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" />
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 @@ -158,7 +167,7 @@ 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
Expand All @@ -168,6 +177,25 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
)}
</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
6 changes: 5 additions & 1 deletion src/components/common/RedeemKeyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { formatNumber } from '@/utils/numberFormat.utils';
import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import { estimateRedeemValue, type RedeemEstimate } from '@/utils/keyDeprecation.utils';
import type { CreatorKeyPriceFields } from '@/utils/keyPriceDisplay.utils';
import { STROOPS_PER_XLM } from '@/constants/stellar';

export interface RedeemKeyDialogProps {
open: boolean;
Expand Down Expand Up @@ -112,7 +113,10 @@ const RedeemKeyDialog: React.FC<RedeemKeyDialogProps> = ({
<span className="text-white/60">Redemption value</span>
<span className="font-semibold text-amber-300/90 tabular-nums">
{estimate
? formatDisplayKeyPrice(estimate.totalValueStroops)
? `${formatNumber(estimate.totalValueStroops / STROOPS_PER_XLM, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} XLM`
: 'Unavailable'}
</span>
</div>
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>
);
}
71 changes: 15 additions & 56 deletions src/components/common/StakingPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,42 @@
import React, { useEffect, useState } from 'react';
import { Clock, Coins } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { formatCountdownTime } from '@/utils/lockupCountdown.utils';
import { computeRemainingClaimSeconds } from '@/utils/stakingClaim.utils';

export interface StakingPanelProps {
/** Identifier of the staked key, passed to the contract as `key_id`. */
keyId: string | number;
/** Unix timestamp (seconds) at which the stake unlocks. */
unlockLedger: number;
/** Called with the key id when the holder claims their unlocked stake. */
key_id: string | number;
unlock_ledger: number;
onClaim: (keyId: string | number) => void | Promise<void>;
/** Whether a claim transaction is currently in flight. */
isClaiming?: boolean;
className?: string;
}

const CARD_CLASS =
'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8';
const getRemainingSeconds = (unlockLedger: number): number =>
Math.max(0, Math.ceil((unlockLedger * 1000 - Date.now()) / 1000));

/**
* Staking panel shown on a held key while its stake is locked (#815).
*
* Displays a live countdown to `unlockLedger` and gates the Claim button on
* that countdown, enabling it automatically once the lock expires without
* requiring a page reload.
*/
const StakingPanel: React.FC<StakingPanelProps> = ({
keyId,
unlockLedger,
onClaim,
isClaiming = false,
className,
}) => {
const StakingPanel: React.FC<StakingPanelProps> = ({ key_id, unlock_ledger, onClaim }) => {
const [remainingSeconds, setRemainingSeconds] = useState(() =>
computeRemainingClaimSeconds(unlockLedger)
getRemainingSeconds(unlock_ledger)
);

useEffect(() => {
const updateRemaining = () =>
setRemainingSeconds(computeRemainingClaimSeconds(unlockLedger));

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

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

const isLocked = remainingSeconds > 0;
const formattedTime = formatCountdownTime(remainingSeconds);

return (
<section className={cn(CARD_CLASS, className)} data-testid="staking-panel">
<div className="mb-6 flex items-center gap-2">
<Coins className="size-5 text-amber-300" aria-hidden="true" />
<h2 className="font-grotesque text-xl font-black tracking-tight text-white">
Staked key
</h2>
</div>

<div
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-xs font-semibold text-amber-300"
data-testid="staking-lock-countdown"
aria-label={
isLocked ? `Stake unlocks in ${formattedTime}` : 'Stake unlocked'
}
>
<Clock className="size-3.5 shrink-0 text-amber-400 animate-pulse" aria-hidden="true" />
<span className="font-mono tabular-nums">{formattedTime}</span>
</div>

<section data-testid="staking-panel">
<span data-testid="staking-lock-countdown">
{formatCountdownTime(remainingSeconds)}
</span>
<Button
className="mt-6"
onClick={() => onClaim(keyId)}
disabled={isLocked || isClaiming}
onClick={() => onClaim(key_id)}
disabled={isLocked}
data-testid="staking-claim-button"
>
{isClaiming ? 'Claiming…' : 'Claim'}
Claim
</Button>
</section>
);
Expand Down
Loading