|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useState, useCallback } from "react"; |
| 4 | + |
| 5 | +interface CollateralUnlockBadgeProps { |
| 6 | + /** The stream's end_time as an ISO string. */ |
| 7 | + endTime: string; |
| 8 | + /** Grace period in seconds after endTime before collateral unlocks. Defaults to 0. */ |
| 9 | + gracePeriodSeconds?: number; |
| 10 | + /** Whether the current user is the sender who locked collateral. */ |
| 11 | + isSender: boolean; |
| 12 | + /** Whether collateral is actually held for this stream. */ |
| 13 | + hasCollateral: boolean; |
| 14 | + /** Collateral amount in stroops (for display). */ |
| 15 | + collateralStroops?: number; |
| 16 | + /** Called when user clicks the claim button. */ |
| 17 | + onClaim?: () => void; |
| 18 | + /** Whether a claim transaction is in progress. */ |
| 19 | + claiming?: boolean; |
| 20 | +} |
| 21 | + |
| 22 | +function computeUnlockRemaining(unlockTimeMs: number) { |
| 23 | + const diff = unlockTimeMs - Date.now(); |
| 24 | + if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, unlocked: true }; |
| 25 | + const totalSeconds = Math.floor(diff / 1000); |
| 26 | + return { |
| 27 | + days: Math.floor(totalSeconds / 86400), |
| 28 | + hours: Math.floor((totalSeconds % 86400) / 3600), |
| 29 | + minutes: Math.floor((totalSeconds % 3600) / 60), |
| 30 | + seconds: totalSeconds % 60, |
| 31 | + unlocked: false, |
| 32 | + }; |
| 33 | +} |
| 34 | + |
| 35 | +export default function CollateralUnlockBadge({ |
| 36 | + endTime, |
| 37 | + gracePeriodSeconds = 0, |
| 38 | + isSender, |
| 39 | + hasCollateral, |
| 40 | + collateralStroops, |
| 41 | + onClaim, |
| 42 | + claiming = false, |
| 43 | +}: CollateralUnlockBadgeProps) { |
| 44 | + const unlockTimeMs = new Date(endTime).getTime() + gracePeriodSeconds * 1000; |
| 45 | + const [remaining, setRemaining] = useState(() => computeUnlockRemaining(unlockTimeMs)); |
| 46 | + const [pulse, setPulse] = useState(false); |
| 47 | + |
| 48 | + // Tick every second |
| 49 | + useEffect(() => { |
| 50 | + const interval = setInterval(() => { |
| 51 | + const next = computeUnlockRemaining(unlockTimeMs); |
| 52 | + setRemaining(next); |
| 53 | + }, 1000); |
| 54 | + return () => clearInterval(interval); |
| 55 | + }, [unlockTimeMs]); |
| 56 | + |
| 57 | + // Pulse animation when unlocked |
| 58 | + useEffect(() => { |
| 59 | + if (remaining.unlocked) { |
| 60 | + setPulse(true); |
| 61 | + const timeout = setTimeout(() => setPulse(false), 2000); |
| 62 | + return () => clearTimeout(timeout); |
| 63 | + } |
| 64 | + }, [remaining.unlocked]); |
| 65 | + |
| 66 | + // Don't render if no collateral or user is not the sender |
| 67 | + if (!hasCollateral || !isSender) { |
| 68 | + return null; |
| 69 | + } |
| 70 | + |
| 71 | + const collateralDisplay = collateralStroops |
| 72 | + ? (collateralStroops / 10_000_000).toFixed(2) |
| 73 | + : null; |
| 74 | + |
| 75 | + // Unlocked state |
| 76 | + if (remaining.unlocked) { |
| 77 | + return ( |
| 78 | + <div |
| 79 | + className={`flex items-center gap-3 bg-green-900/40 border border-green-700/50 rounded-lg px-4 py-3 transition-all ${ |
| 80 | + pulse ? "animate-pulse" : "" |
| 81 | + }`} |
| 82 | + role="status" |
| 83 | + aria-label="Collateral has been released" |
| 84 | + > |
| 85 | + <div className="flex-shrink-0"> |
| 86 | + <svg |
| 87 | + className="h-6 w-6 text-green-400" |
| 88 | + xmlns="http://www.w3.org/2000/svg" |
| 89 | + viewBox="0 0 24 24" |
| 90 | + fill="none" |
| 91 | + stroke="currentColor" |
| 92 | + strokeWidth="2" |
| 93 | + strokeLinecap="round" |
| 94 | + strokeLinejoin="round" |
| 95 | + aria-hidden="true" |
| 96 | + > |
| 97 | + <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /> |
| 98 | + <polyline points="22 4 12 14.01 9 11.01" /> |
| 99 | + </svg> |
| 100 | + </div> |
| 101 | + <div className="flex-1 min-w-0"> |
| 102 | + <p className="text-sm font-semibold text-green-300">Collateral Released</p> |
| 103 | + <p className="text-xs text-green-400/70"> |
| 104 | + {collateralDisplay |
| 105 | + ? `${collateralDisplay} XLM available to claim` |
| 106 | + : "Your collateral is now available"} |
| 107 | + </p> |
| 108 | + </div> |
| 109 | + {onClaim && ( |
| 110 | + <button |
| 111 | + onClick={onClaim} |
| 112 | + disabled={claiming} |
| 113 | + className="flex-shrink-0 bg-green-700 hover:bg-green-600 disabled:opacity-50 text-white text-xs font-medium px-3 py-1.5 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-400" |
| 114 | + > |
| 115 | + {claiming ? "Claiming…" : "Claim"} |
| 116 | + </button> |
| 117 | + )} |
| 118 | + </div> |
| 119 | + ); |
| 120 | + } |
| 121 | + |
| 122 | + // Countdown state |
| 123 | + const parts: { label: string; value: number }[] = [ |
| 124 | + { label: "d", value: remaining.days }, |
| 125 | + { label: "h", value: remaining.hours }, |
| 126 | + { label: "m", value: remaining.minutes }, |
| 127 | + { label: "s", value: remaining.seconds }, |
| 128 | + ]; |
| 129 | + |
| 130 | + return ( |
| 131 | + <div |
| 132 | + className="bg-amber-900/30 border border-amber-700/40 rounded-lg px-4 py-3" |
| 133 | + role="status" |
| 134 | + aria-label="Collateral unlock countdown" |
| 135 | + > |
| 136 | + <div className="flex items-start gap-3"> |
| 137 | + <div className="flex-shrink-0 mt-0.5"> |
| 138 | + <svg |
| 139 | + className="h-5 w-5 text-amber-400" |
| 140 | + xmlns="http://www.w3.org/2000/svg" |
| 141 | + viewBox="0 0 24 24" |
| 142 | + fill="none" |
| 143 | + stroke="currentColor" |
| 144 | + strokeWidth="2" |
| 145 | + strokeLinecap="round" |
| 146 | + strokeLinejoin="round" |
| 147 | + aria-hidden="true" |
| 148 | + > |
| 149 | + <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> |
| 150 | + <path d="M7 11V7a5 5 0 0 1 10 0v4" /> |
| 151 | + </svg> |
| 152 | + </div> |
| 153 | + <div className="flex-1 min-w-0"> |
| 154 | + <p className="text-sm font-semibold text-amber-300">Collateral Locked</p> |
| 155 | + {collateralDisplay && ( |
| 156 | + <p className="text-xs text-amber-400/70 mt-0.5">{collateralDisplay} XLM locked</p> |
| 157 | + )} |
| 158 | + <div className="flex items-center gap-2 mt-2 font-mono" aria-label="Time until unlock"> |
| 159 | + {parts.map((p) => ( |
| 160 | + <span key={p.label} className="flex items-baseline gap-0.5"> |
| 161 | + <span className="text-lg font-bold tabular-nums text-amber-200"> |
| 162 | + {String(p.value).padStart(p.label === "d" ? 1 : 2, "0")} |
| 163 | + </span> |
| 164 | + <span className="text-[10px] uppercase tracking-wider text-amber-400/60"> |
| 165 | + {p.label} |
| 166 | + </span> |
| 167 | + </span> |
| 168 | + ))} |
| 169 | + </div> |
| 170 | + </div> |
| 171 | + </div> |
| 172 | + </div> |
| 173 | + ); |
| 174 | +} |
0 commit comments