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
2 changes: 1 addition & 1 deletion src/components/common/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ function ConnectWalletButton() {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">
Connected Wallet
Wallet address
</span>
<button
type="button"
Expand Down
149 changes: 0 additions & 149 deletions src/components/common/KeySimulationTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,155 +181,6 @@ const KeySimulationTool: React.FC<KeySimulationToolProps> = ({
</span>
</div>
</div>
import React, { useEffect, useRef, useState } from 'react';
import { courseService } from '@/services/course.service';
import {
calculatePriceImpact,
formatPriceImpact,
} from '@/utils/priceImpact.utils';

export interface KeySimulationToolProps {
/** Key identifier used for GET /keys/:keyId/simulate?quantity=N */
keyId: string;
/** Current spot price in the same unit as simulated_price (e.g. XLM or stroops) */
spotPrice: number;
/** Optional initial quantity */
initialQuantity?: number;
}

interface SimulateResult {
simulated_price?: number;
simulatedPrice?: number;
spot_price?: number;
spotPrice?: number;
}

/**
* Key price simulation tool (#887).
*
* Lets the user enter a custom quantity, debounces the input by 300ms,
* fetches GET /keys/:keyId/simulate?quantity=N, computes price impact as
* (simulated_price - spot_price) / spot_price * 100 and displays it.
*
* Loading shows a skeleton, fetch errors show 'Unable to simulate price'
* and hide the impact value.
*/
const KeySimulationTool: React.FC<KeySimulationToolProps> = ({
keyId,
spotPrice,
initialQuantity = 1,
}) => {
const [quantityInput, setQuantityInput] = useState(
String(initialQuantity)
);
const [simulatedPrice, setSimulatedPrice] = useState<number | null>(null);
const [resolvedSpotPrice, setResolvedSpotPrice] = useState<number>(spotPrice);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Keep spot price in sync when prop changes
useEffect(() => {
setResolvedSpotPrice(spotPrice);
}, [spotPrice]);

useEffect(() => {
const quantity = Number(quantityInput);
// Empty or invalid quantity: clear simulation
if (quantityInput.trim() === '' || isNaN(quantity) || quantity <= 0) {
setSimulatedPrice(null);
setError(null);
setLoading(false);
return;
}

if (debounceRef.current) clearTimeout(debounceRef.current);

setLoading(true);
setError(null);

debounceRef.current = setTimeout(async () => {
try {
const result: SimulateResult =
await courseService.simulateBuy(keyId, quantity);
// Support both snake_case and camelCase shapes
const sim =
result.simulated_price ?? result.simulatedPrice ?? null;
const spot =
result.spot_price ?? result.spotPrice ?? spotPrice;
if (sim != null) {
setSimulatedPrice(sim);
if (spot != null) setResolvedSpotPrice(spot);
setError(null);
} else {
setSimulatedPrice(null);
}
} catch {
setError('Unable to simulate price');
setSimulatedPrice(null);
} finally {
setLoading(false);
}
}, 300);

return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [quantityInput, keyId, spotPrice]);

const impact =
simulatedPrice != null
? calculatePriceImpact(simulatedPrice, resolvedSpotPrice)
: null;

return (
<div className="space-y-4" data-testid="key-simulation-tool">
<div className="space-y-1.5">
<label
htmlFor="simulation-quantity"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Quantity
</label>
<input
id="simulation-quantity"
data-testid="simulation-quantity-input"
aria-label="Custom quantity"
type="number"
inputMode="numeric"
min={1}
value={quantityInput}
onChange={e => setQuantityInput(e.target.value)}
className="w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none"
placeholder="Enter quantity"
/>
</div>

{loading && (
<div
data-testid="simulation-skeleton"
aria-label="Loading simulation"
className="h-6 w-32 animate-pulse rounded bg-white/10"
/>
)}

{!loading && error && (
<p
role="alert"
data-testid="simulation-error"
className="text-sm text-red-400"
>
{error}
</p>
)}

{!loading && !error && impact != null && (
<p
data-testid="price-impact"
className="text-sm font-bold text-white"
>
{formatPriceImpact(impact)}
</p>
)}
</div>
);
Expand Down
108 changes: 108 additions & 0 deletions src/components/common/ProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { cn } from '@/lib/utils';
import { Clock, ThumbsUp, ThumbsDown, Minus } from 'lucide-react';
import QuorumIndicator from '@/components/common/QuorumIndicator';
import type { Proposal } from '@/types/governance';
import { formatCompactNumber } from '@/utils/numberFormat.utils';

interface ProposalCardProps {
proposal: Proposal;
className?: string;
}

const statusClasses: Record<Proposal['status'], string> = {
active: 'border-amber-500/30 bg-amber-500/10 text-amber-400',
passed: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400',
rejected: 'border-red-500/30 bg-red-500/10 text-red-400',
executed: 'border-blue-500/30 bg-blue-500/10 text-blue-400',
cancelled: 'border-white/10 bg-white/[0.04] text-white/40',
};

function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}

/**
* Proposal card with quorum progress indicator (#826).
*
* Shows the proposal title, description, vote tallies, time remaining,
* and a quorum progress bar so voters can see whether participation is
* on track before voting ends.
*/
const ProposalCard: React.FC<ProposalCardProps> = ({ proposal, className }) => {
const isActive = proposal.status === 'active';
const totalVotes =
proposal.forVotes + proposal.againstVotes + proposal.abstainVotes;

return (
<div
className={cn(
'rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 transition-all duration-200',
isActive && 'hover:border-amber-500/20 hover:bg-white/[0.05]',
className
)}
>
{/* Header row: status + title */}
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="font-jakarta text-base font-bold text-white leading-snug">
{proposal.title}
</h3>
<span
className={cn(
'shrink-0 rounded-full border px-2.5 py-0.5 text-[0.65rem] font-semibold capitalize',
statusClasses[proposal.status]
)}
>
{proposal.status}
</span>
</div>

{/* Description */}
<p className="mb-4 text-sm leading-relaxed text-white/60 line-clamp-2">
{proposal.description}
</p>

{/* Vote tallies */}
<div className="mb-4 flex items-center gap-4 text-xs text-white/50">
<span className="inline-flex items-center gap-1">
<ThumbsUp className="size-3 text-emerald-400" aria-hidden="true" />
{formatCompactNumber(proposal.forVotes)}
</span>
<span className="inline-flex items-center gap-1">
<ThumbsDown className="size-3 text-red-400" aria-hidden="true" />
{formatCompactNumber(proposal.againstVotes)}
</span>
<span className="inline-flex items-center gap-1">
<Minus className="size-3 text-white/40" aria-hidden="true" />
{formatCompactNumber(proposal.abstainVotes)}
</span>
<span className="ml-auto tabular-nums text-white/40">
{formatCompactNumber(totalVotes)} votes
</span>
</div>

{/* Quorum indicator — core of #826 */}
{isActive && (
<QuorumIndicator
quorumBps={proposal.quorumBps}
totalVotingWeight={proposal.totalVotingWeight}
totalCirculatingSupply={proposal.totalCirculatingSupply}
className="mb-4"
/>
)}

{/* Footer: dates */}
<div className="flex items-center gap-1.5 text-xs text-white/35">
<Clock className="size-3" aria-hidden="true" />
<span>
{formatDate(proposal.startDate)} — {formatDate(proposal.endDate)}
</span>
</div>
</div>
);
};

export default ProposalCard;
97 changes: 97 additions & 0 deletions src/components/common/QuorumIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { CheckCircle, AlertCircle } from 'lucide-react';
import type { QuorumIndicatorProps } from '@/types/governance';

/**
* Governance quorum indicator (#826).
*
* Renders a progress bar filled to the current participation percentage,
* a marker at the quorum threshold, and a green / amber status label.
*
* Acceptance criteria:
* - Participation percentage computed and bar filled correctly
* - Quorum threshold marker shown at the correct position
* - 'Quorum reached' shown in green when participation meets the threshold
* - 'Quorum not yet reached' shown in amber when below threshold
* - Bar updates after each vote (caller passes refreshed data)
*/
const QuorumIndicator: React.FC<QuorumIndicatorProps> = ({
quorumBps,
totalVotingWeight,
totalCirculatingSupply,
className,
}) => {
const { participationPct, quorumPct, quorumReached } = useMemo(() => {
if (!totalCirculatingSupply || totalCirculatingSupply <= 0) {
return { participationPct: 0, quorumPct: 0, quorumReached: false };
}

const participation = (totalVotingWeight / totalCirculatingSupply) * 100;
const quorum = quorumBps / 100; // basis points → percentage

return {
participationPct: Math.min(participation, 100),
quorumPct: Math.min(quorum, 100),
quorumReached: participation >= quorum,
};
}, [quorumBps, totalVotingWeight, totalCirculatingSupply]);

return (
<div
className={cn('space-y-1.5', className)}
role="status"
aria-label={
quorumReached
? `Quorum reached: ${participationPct.toFixed(1)}% participation`
: `Quorum not yet reached: ${participationPct.toFixed(1)}% participation, ${quorumPct}% required`
}
>
{/* Progress bar track */}
<div className="relative h-2 w-full overflow-hidden rounded-full bg-white/[0.08]">
{/* Filled portion — participation */}
<div
className={cn(
'absolute inset-y-0 left-0 rounded-full transition-all duration-700 ease-out',
quorumReached
? 'bg-emerald-500'
: 'bg-amber-400'
)}
style={{ width: `${participationPct}%` }}
aria-hidden="true"
/>

{/* Quorum threshold marker */}
<div
className="absolute inset-y-0 w-0.5 bg-white/70"
style={{ left: `${quorumPct}%` }}
aria-hidden="true"
/>
</div>

{/* Labels row */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1">
{quorumReached ? (
<CheckCircle className="size-3.5 text-emerald-400" aria-hidden="true" />
) : (
<AlertCircle className="size-3.5 text-amber-400" aria-hidden="true" />
)}
<span
className={cn(
'font-semibold',
quorumReached ? 'text-emerald-400' : 'text-amber-400'
)}
>
{quorumReached ? 'Quorum reached' : 'Quorum not yet reached'}
</span>
</div>
<span className="tabular-nums text-white/50">
{participationPct.toFixed(1)}% / {quorumPct.toFixed(0)}%
</span>
</div>
</div>
);
};

export default QuorumIndicator;
Loading
Loading