diff --git a/src/components/common/ExitNoticeBanner.tsx b/src/components/common/ExitNoticeBanner.tsx new file mode 100644 index 00000000..0c8270ef --- /dev/null +++ b/src/components/common/ExitNoticeBanner.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, Clock, Coins, ArrowRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + resolveExitNotice, + hasFullyExitedPosition, + type ExitNoticeFields, +} from '@/utils/exitNotice.utils'; +import { + computeRemainingCooldownSeconds, + formatCooldownDuration, +} from '@/utils/buyCooldown.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; + +export interface ExitNoticeBannerProps { + creator: ExitNoticeFields; + holdingsCount?: number; + onSellNow?: () => void; + className?: string; +} + +const ACTION_LABELS = { + deprecation: 'Deprecation', + retirement: 'Retirement', +} as const; + +/** + * Urgent exit-notice banner for a key entering deprecation or retirement + * notice (#1246). Shown on the key detail page and on each portfolio + * holding row. Dismisses itself once the holder's position reaches zero + * (`hasFullyExitedPosition`) — there is nothing left to act on. + */ +export const ExitNoticeBanner: React.FC = ({ + creator, + holdingsCount = 0, + onSellNow, + className, +}) => { + const notice = resolveExitNotice(creator); + + const [remainingSeconds, setRemainingSeconds] = useState(() => + computeRemainingCooldownSeconds(notice?.noticeExpiresAt) + ); + + useEffect(() => { + if (!notice?.noticeExpiresAt) { + setRemainingSeconds(0); + return; + } + + setRemainingSeconds(computeRemainingCooldownSeconds(notice.noticeExpiresAt)); + const intervalId = setInterval(() => { + setRemainingSeconds(computeRemainingCooldownSeconds(notice.noticeExpiresAt)); + }, 1000); + + return () => clearInterval(intervalId); + }, [notice?.noticeExpiresAt]); + + if (!notice || hasFullyExitedPosition(holdingsCount)) { + return null; + } + + const isRetirement = notice.actionType === 'retirement'; + const hasDeadline = Boolean(notice.noticeExpiresAt); + const expired = hasDeadline && remainingSeconds <= 0; + + return ( +
+
+
+
+ + + {isRetirement && ( + + Guaranteed Contract Buyback Active + + )} +
+ +

+ {isRetirement + ? 'This key is being retired. Trading has ceased permanently. Sell your position now at the guaranteed buyback price before the notice window closes.' + : creator.deprecationReason ?? + 'This creator key has been deprecated. Trading on the open market has ceased.'} +

+ + {hasDeadline && ( +
+ + + {expired + ? 'Notice window closed' + : `Notice closes in ${formatCooldownDuration(remainingSeconds)}`} + +
+ )} +
+ +
+ {isRetirement && notice.buybackPriceStroops != null && ( +
+
+ +
+
+
+ Buyback Price +
+
+ {formatDisplayKeyPrice(notice.buybackPriceStroops)} +
+
+
+ )} + + +
+
+
+ ); +}; + +export default ExitNoticeBanner; diff --git a/src/components/common/HolderConcentrationHeatmap.tsx b/src/components/common/HolderConcentrationHeatmap.tsx new file mode 100644 index 00000000..a89ab300 --- /dev/null +++ b/src/components/common/HolderConcentrationHeatmap.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { cn } from '@/lib/utils'; +import Skeleton from '@/components/ui/skeleton'; +import { truncateAddress } from '@/utils/whitelist.utils'; +import { formatNumber, formatPercent } from '@/utils/numberFormat.utils'; +import { useHolderConcentration } from '@/hooks/useHolderConcentration'; +import type { + HolderTier, + HolderTierBreakdown, +} from '@/services/holderConcentration.service'; + +export interface HolderConcentrationHeatmapProps { + keyId: string; + className?: string; +} + +const TIER_LABELS: Record = { + whale: 'Whale', + midTier: 'Mid-tier', + retail: 'Retail', +}; + +const TIER_DESCRIPTIONS: Record = { + whale: '> 5% of supply', + midTier: '1 – 5% of supply', + retail: '< 1% of supply', +}; + +const TIER_COLORS: Record = { + whale: '#fbbf24', // amber-400 + midTier: '#38bdf8', // sky-400 + retail: '#a78bfa', // violet-400 +}; + +const TIER_ORDER: HolderTier[] = ['whale', 'midTier', 'retail']; + +function orderTiers(tiers: HolderTierBreakdown[]): HolderTierBreakdown[] { + const byTier = new Map(tiers.map(tier => [tier.tier, tier])); + return TIER_ORDER.map( + tier => + byTier.get(tier) ?? { tier, sharePercent: 0, walletCount: 0 } + ); +} + +interface DonutTooltipPayload { + name: string; + value: number; + payload: HolderTierBreakdown; +} + +const DonutTooltip: React.FC<{ active?: boolean; payload?: DonutTooltipPayload[] }> = ({ + active, + payload, +}) => { + if (!active || !payload?.length) return null; + const entry = payload[0]!.payload; + return ( +
+

{TIER_LABELS[entry.tier]}

+

+ {formatPercent(entry.sharePercent, { maximumFractionDigits: 1 })} ·{' '} + {formatNumber(entry.walletCount)} wallet + {entry.walletCount === 1 ? '' : 's'} +

+
+ ); +}; + +export const HolderConcentrationHeatmap: React.FC< + HolderConcentrationHeatmapProps +> = ({ keyId, className }) => { + const { data, isLoading, isError } = useHolderConcentration(keyId); + + if (isLoading) { + return ( +
+
+ +
+ + + +
+
+
+ ); + } + + if (isError || !data) { + return ( +

+ No holder concentration data yet. +

+ ); + } + + const tiers = orderTiers(data.tiers); + const hasAnyShare = tiers.some(tier => tier.sharePercent > 0); + + return ( +
+
+

+ Distribution of circulating supply across wallet tiers. +

+
+

+ {formatNumber(data.uniqueHolderCount)} +

+

+ Unique holders +

+
+
+ + {!hasAnyShare ? ( +

+ No holder concentration data yet. +

+ ) : ( +
+
+ + + + {tiers.map(tier => ( + + ))} + + } /> + + +
+ +
    + {tiers.map(tier => ( +
  • + + + + {formatPercent(tier.sharePercent, { + maximumFractionDigits: 1, + })} + +
  • + ))} +
+
+ )} + +
+

+ Top 5 Holders +

+ {data.topHolders.length === 0 ? ( +

No holders yet.

+ ) : ( +
+ + + + + + + + + + {data.topHolders.slice(0, 5).map(holder => ( + + + + + + ))} + +
WalletAmount% of Supply
+ {truncateAddress(holder.walletAddress)} + + {formatNumber(holder.keyCount)} + + {formatPercent(holder.sharePercent, { + maximumFractionDigits: 1, + })} +
+
+ )} +
+
+ ); +}; + +export default HolderConcentrationHeatmap; diff --git a/src/components/common/PortfolioHoldingRow.tsx b/src/components/common/PortfolioHoldingRow.tsx index d54f7632..e14496fc 100644 --- a/src/components/common/PortfolioHoldingRow.tsx +++ b/src/components/common/PortfolioHoldingRow.tsx @@ -3,6 +3,9 @@ import { Button } from '@/components/ui/button'; import LockupCountdown from '@/components/common/LockupCountdown'; import ReinvestDividendDialog from '@/components/common/ReinvestDividendDialog'; import DeprecationNotice from '@/components/common/DeprecationNotice'; +import ExitNoticeBanner from '@/components/common/ExitNoticeBanner'; +import ReportIssueButton from '@/components/common/ReportIssueButton'; +import SocialShareButton from '@/components/common/SocialShareButton'; import RedeemKeyDialog from '@/components/common/RedeemKeyDialog'; import BatchTransferModal from '@/components/common/BatchTransferModal'; import { computeRemainingLockupSeconds } from '@/utils/lockupCountdown.utils'; @@ -162,6 +165,14 @@ export const PortfolioHoldingRow: React.FC = ({ return ( <> + {creator?.isRetiring && ( + onSell?.(position.creatorId)} + className="mb-3" + /> + )}
= ({ )} + {creator && ( + + )} + {creator && ( + + )}
{formatNumber(position.quantity)} keys ·{' '} {position.isPriceLoading diff --git a/src/components/common/ReportIssueButton.tsx b/src/components/common/ReportIssueButton.tsx new file mode 100644 index 00000000..b33d95b0 --- /dev/null +++ b/src/components/common/ReportIssueButton.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Flag } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import SupportTicketDialog from '@/components/common/SupportTicketDialog'; + +export interface ReportIssueButtonProps { + keyId: string; + keyName?: string; + className?: string; +} + +/** + * "Report Issue" trigger for the support-ticket flow (#1250), shown on + * both the key detail page and each portfolio holding row. + */ +export const ReportIssueButton: React.FC = ({ + keyId, + keyName, + className, +}) => { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + ); +}; + +export default ReportIssueButton; diff --git a/src/components/common/SocialShareButton.tsx b/src/components/common/SocialShareButton.tsx new file mode 100644 index 00000000..50da2069 --- /dev/null +++ b/src/components/common/SocialShareButton.tsx @@ -0,0 +1,50 @@ +import { useState } from 'react'; +import { Share2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import SocialShareModal from '@/components/common/SocialShareModal'; +import type { SocialShareInput } from '@/utils/socialShare.utils'; + +export interface SocialShareButtonProps { + share: SocialShareInput; + title?: string; + className?: string; + 'data-testid'?: string; +} + +/** + * "Share" trigger for the social sharing flow (#1251), shown on the + * KeyDetail page, portfolio holding rows, and badge cards. + */ +export default function SocialShareButton({ + share, + title, + className, + 'data-testid': testId = 'social-share-button', +}: SocialShareButtonProps) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + + ); +} diff --git a/src/components/common/SocialShareModal.tsx b/src/components/common/SocialShareModal.tsx new file mode 100644 index 00000000..cd8403bb --- /dev/null +++ b/src/components/common/SocialShareModal.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { Copy, Check, Send, Sparkles } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + buildSocialShareText, + buildTelegramShareUrl, + buildTwitterShareUrl, + openShareWindow, + type SocialShareInput, +} from '@/utils/socialShare.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; +import showToast from '@/utils/toast.util'; + +export interface SocialShareModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + share: SocialShareInput; + title?: string; +} + +/** + * Share modal for key positions and badge achievements (#1251): pre-filled + * Twitter/Telegram intents, plus Copy Link. Shared across the KeyDetail + * page, portfolio holding rows, and badge cards. + */ +export default function SocialShareModal({ + open, + onOpenChange, + share, + title = 'Share', +}: SocialShareModalProps) { + const [copied, setCopied] = useState(false); + + const shareText = buildSocialShareText(share); + const twitterUrl = buildTwitterShareUrl(shareText, share.url); + const telegramUrl = buildTelegramShareUrl(shareText, share.url); + + const handleCopyLink = async () => { + try { + await copyTextToClipboard(share.url); + setCopied(true); + showToast.success('Link copied to clipboard!'); + setTimeout(() => setCopied(false), 2500); + } catch { + showToast.error('Failed to copy link.'); + } + }; + + return ( + { + if (!next) setCopied(false); + onOpenChange(next); + }} + > + + +
+
+ +
+ + {title} + +
+ + Share this on Twitter or Telegram, or copy the link. + +
+ +
+
+ {shareText} +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + + + +
+
+ ); +} diff --git a/src/components/common/SupportTicketDialog.tsx b/src/components/common/SupportTicketDialog.tsx new file mode 100644 index 00000000..6022e1b7 --- /dev/null +++ b/src/components/common/SupportTicketDialog.tsx @@ -0,0 +1,244 @@ +import { useEffect, 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 { + SUPPORT_TICKET_ISSUE_TYPES, + SUPPORT_TICKET_ISSUE_TYPE_LABELS, + type SupportTicketIssueType, +} from '@/services/supportTicket.service'; +import { + SUPPORT_TICKET_DESCRIPTION_MAX_LENGTH, + validateSupportTicketDraft, +} from '@/utils/supportTicket.utils'; +import { useSubmitSupportTicket } from '@/hooks/useSupportTickets'; + +const fieldClass = + '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 transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50'; + +export interface SupportTicketDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + keyId: string; + keyName?: string; +} + +/** + * Report-an-issue form for a specific key (#1250): frozen trading, a + * disputed transaction, an incorrect balance, or an unlisted issue, + * routed to admin review. Shown from the "Report Issue" button on the + * key detail page and each portfolio holding row. + */ +const SupportTicketDialog: React.FC = ({ + open, + onOpenChange, + keyId, + keyName, +}) => { + const triggerElementRef = useRef(null); + const [issueType, setIssueType] = useState(''); + const [description, setDescription] = useState(''); + const [transactionHash, setTransactionHash] = useState(''); + const [showError, setShowError] = useState(false); + + const submit = useSubmitSupportTicket(); + const isSubmitting = submit.isPending; + + useEffect(() => { + if (open) { + triggerElementRef.current = document.activeElement as HTMLElement | null; + } else { + setIssueType(''); + setDescription(''); + setTransactionHash(''); + setShowError(false); + } + }, [open]); + + const validationError = validateSupportTicketDraft({ + issueType, + description, + transactionHash, + }); + const remaining = SUPPORT_TICKET_DESCRIPTION_MAX_LENGTH - description.length; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + if (validationError || !issueType) { + setShowError(true); + return; + } + + submit.mutate( + { + keyId, + issueType, + description: description.trim(), + transactionHash: transactionHash.trim() || undefined, + }, + { + onSuccess: () => { + onOpenChange(false); + }, + } + ); + }; + + return ( + !isSubmitting && onOpenChange(next)} + > + { + event.preventDefault(); + triggerElementRef.current?.focus(); + }} + onEscapeKeyDown={event => { + if (isSubmitting) event.preventDefault(); + }} + onInteractOutside={event => { + if (isSubmitting) event.preventDefault(); + }} + > + + Report an issue + + Report a problem with {keyName ?? 'this key'}. Your ticket is + routed to admin review. + + + +
+
+ + +
+ +
+
+ + + {description.length}/{SUPPORT_TICKET_DESCRIPTION_MAX_LENGTH} + +
+