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
55 changes: 55 additions & 0 deletions src/components/common/PortfolioHoldingRow.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import LockupCountdown from '@/components/common/LockupCountdown';
import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog';
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';
import { TrendingUp } from 'lucide-react';
import type { HeldKeyPosition } from '@/utils/portfolioValue.utils';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
Expand All @@ -13,7 +16,9 @@ export interface PortfolioHoldingRowProps {
creator?: Course;
onBuy?: (creatorId: string) => void;
onSell?: (creatorId: string) => void;
onReinvest?: (creatorId: string) => Promise<void> | void;
isSubmitting?: boolean;
isReinvesting?: boolean;
isNetworkMismatch?: boolean;
}

Expand All @@ -22,13 +27,26 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
creator,
onBuy,
onSell,
onReinvest,
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);
};

return (
<>
<div
className={cn(
'flex flex-col gap-3 rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-opacity sm:flex-row sm:items-center sm:justify-between',
Expand Down Expand Up @@ -56,6 +74,18 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
? 'Price stale'
: formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(position))}
</div>
{hasDividends && (
<span
className="mt-2 inline-flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[0.65rem] font-semibold text-emerald-400"
title="Unclaimed dividend balance available to reinvest"
data-testid="unclaimed-dividend-badge"
>
<TrendingUp className="size-3" aria-hidden="true" />
<span>
{formatDisplayKeyPrice(xlmToStroops(position.unclaimedDividend))} unclaimed
</span>
</span>
)}
</div>

<div className="flex items-center gap-3 shrink-0">
Expand All @@ -65,6 +95,18 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
/>

<div className="flex items-center gap-2">
{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"
Expand All @@ -91,6 +133,19 @@ export const PortfolioHoldingRow: React.FC<PortfolioHoldingRowProps> = ({
</div>
</div>
</div>

{onReinvest && hasDividends && position.unclaimedDividend != null && (
<ReinvestDividendDialog
open={reinvestOpen}
creatorName={creator?.title ?? 'this creator'}
unclaimedDividend={position.unclaimedDividend}
keyPriceStroops={keyPriceStroops}
onOpenChange={setReinvestOpen}
onConfirm={handleConfirmReinvest}
isSubmitting={isReinvesting}
/>
)}
</>
);
};

Expand Down
163 changes: 163 additions & 0 deletions src/components/common/ReinvestDividendDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useEffect, useRef } 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 { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import {
estimateReinvest,
type ReinvestEstimate,
} from '@/utils/reinvestDividend.utils';

export interface ReinvestDividendDialogProps {
open: boolean;
/** Creator key name being reinvested into. */
creatorName: string;
/** Unclaimed dividend balance in XLM. */
unclaimedDividend: number;
/** Per-key price in stroops used to estimate the purchase. */
keyPriceStroops?: number | null;
onOpenChange: (open: boolean) => void;
onConfirm: () => Promise<void> | void;
isSubmitting?: boolean;
}

/**
* Confirmation modal for reinvesting an unclaimed dividend balance back into
* creator keys (#824). Shows the unclaimed amount, the estimated keys to be
* bought, and any XLM remainder that cannot be converted to a whole key.
*/
const ReinvestDividendDialog: React.FC<ReinvestDividendDialogProps> = ({
open,
creatorName,
unclaimedDividend,
keyPriceStroops,
onOpenChange,
onConfirm,
isSubmitting = false,
}) => {
const triggerElementRef = useRef<HTMLElement | null>(null);

useEffect(() => {
if (open) {
triggerElementRef.current =
document.activeElement as HTMLElement | null;
}
}, [open]);

const estimate: ReinvestEstimate | null = estimateReinvest(
unclaimedDividend,
keyPriceStroops
);

const confirmDisabled =
isSubmitting ||
estimate == null ||
estimate.wholeKeys <= 0 ||
estimate.unclaimedStroops <= 0;

const handleConfirm = async () => {
if (confirmDisabled) return;
await onConfirm();
};

return (
<Dialog
open={open}
onOpenChange={next => !isSubmitting && onOpenChange(next)}
>
<DialogContent
className="max-w-md"
showCloseButton={!isSubmitting}
showEscapeHint={!isSubmitting}
onCloseAutoFocus={event => {
event.preventDefault();
triggerElementRef.current?.focus();
}}
onEscapeKeyDown={event => {
if (isSubmitting) event.preventDefault();
}}
onInteractOutside={event => {
if (isSubmitting) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Reinvest dividends</DialogTitle>
<DialogDescription>
Compound your unclaimed{' '}
{formatDisplayKeyPrice(estimate?.unclaimedStroops)} dividend
into more {creatorName} creator keys.
</DialogDescription>
</DialogHeader>

<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-white/60">Unclaimed dividends</span>
<span className="font-semibold text-white tabular-nums">
{formatDisplayKeyPrice(estimate?.unclaimedStroops)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/60">Per-key price</span>
<span className="font-semibold text-white tabular-nums">
{formatDisplayKeyPrice(keyPriceStroops)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/60">Estimated keys</span>
<span className="font-semibold text-amber-300/90 tabular-nums">
{estimate
? `${formatNumber(estimate.wholeKeys)} key${
estimate.wholeKeys === 1 ? '' : 's'
}`
: 'Unavailable'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/60">XLM remainder</span>
<span className="font-semibold text-white tabular-nums">
{estimate
? formatDisplayKeyPrice(estimate.remainderStroops)
: '—'}
</span>
</div>
</div>

<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
data-testid="reinvest-dialog-cancel"
>
Cancel
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={confirmDisabled}
aria-busy={isSubmitting || undefined}
data-testid="reinvest-dialog-confirm"
>
<StableButtonContent
isLoading={isSubmitting}
loadingLabel="Submitting…"
>
Confirm reinvest
</StableButtonContent>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default ReinvestDividendDialog;
123 changes: 123 additions & 0 deletions src/components/common/__tests__/PortfolioHoldingRow.reinvest.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import PortfolioHoldingRow from '../PortfolioHoldingRow';
import type { HeldKeyPosition } from '@/utils/portfolioValue.utils';

const basePosition: HeldKeyPosition = {
creatorId: 'creator-1',
quantity: 5,
priceStroops: 5_000_000,
last_buy_timestamp: null,
};

const creator = {
id: 'creator-1',
title: 'Alex Rivers',
description: 'Artist',
price: 0.5,
instructorId: 'inst-1',
category: 'Art',
level: 'BEGINNER' as const,
};

describe('PortfolioHoldingRow — dividend reinvest', () => {
it('shows an unclaimed dividend badge only when unclaimedDividend is greater than zero', () => {
const { rerender } = render(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 1.25 }}
creator={creator}
onReinvest={vi.fn()}
/>
);

expect(
screen.getByTestId('unclaimed-dividend-badge')
).toBeInTheDocument();
expect(screen.getByTestId('holding-reinvest-button')).toBeInTheDocument();

rerender(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 0 }}
creator={creator}
onReinvest={vi.fn()}
/>
);

expect(
screen.queryByTestId('unclaimed-dividend-badge')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('holding-reinvest-button')
).not.toBeInTheDocument();
});

it('does not render a reinvest button when no onReinvest handler is supplied', () => {
render(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 1.25 }}
creator={creator}
/>
);

expect(
screen.getByTestId('unclaimed-dividend-badge')
).toBeInTheDocument();
expect(
screen.queryByTestId('holding-reinvest-button')
).not.toBeInTheDocument();
});

it('opens the confirmation modal with the estimated keys and remainder', () => {
render(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 1.25 }}
creator={creator}
onReinvest={vi.fn()}
/>
);

fireEvent.click(screen.getByTestId('holding-reinvest-button'));

expect(screen.getByTestId('reinvest-dialog-confirm')).toBeInTheDocument();
// 1.25 XLM at 0.5 XLM/key => 2 whole keys; remainder 0.25 XLM
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
expect(screen.getByText(/Unclaimed dividends/i)).toBeInTheDocument();
});

it('calls onReinvest with the creator key id on confirm', async () => {
const onReinvest = vi.fn().mockResolvedValue(undefined);

render(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 1.25 }}
creator={creator}
onReinvest={onReinvest}
/>
);

fireEvent.click(screen.getByTestId('holding-reinvest-button'));

const confirmButton = screen.getByTestId('reinvest-dialog-confirm');
expect(confirmButton).not.toBeDisabled();

fireEvent.click(confirmButton);

expect(onReinvest).toHaveBeenCalledWith('creator-1');
});

it('disables the confirm button when the dividend cannot buy a whole key', () => {
render(
<PortfolioHoldingRow
position={{ ...basePosition, unclaimedDividend: 0.1 }}
creator={creator}
onReinvest={vi.fn()}
/>
);

fireEvent.click(screen.getByTestId('holding-reinvest-button'));

const confirmButton = screen.getByTestId('reinvest-dialog-confirm');
// 0.1 XLM at 0.5 XLM/key < 1 key => disabled
expect(confirmButton).toBeDisabled();
});
});
Loading
Loading