-
Notifications
You must be signed in to change notification settings - Fork 70
feat: implement expert availability toggle, transparency log, and rent estimation #385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b90e7e9
8a9c2ce
c823e37
226c7ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import React from "react" | ||
|
|
||
| const ADMIN_EVENTS = [ | ||
| { | ||
| id: "1", | ||
| timestamp: "2024-06-25T14:30:00Z", | ||
| action: "Set Platform Fee", | ||
| details: "Fee updated to 2.5%", | ||
| caller: "GBXYZ...ABCD", | ||
| txHash: "9a8b7c6d5e4f3a2b1c0d...", | ||
| }, | ||
| { | ||
| id: "2", | ||
| timestamp: "2024-06-20T09:15:00Z", | ||
| action: "Pause Contract", | ||
| details: "Emergency pause activated", | ||
| caller: "GBXYZ...ABCD", | ||
| txHash: "1a2b3c4d5e6f7a8b9c0d...", | ||
| }, | ||
| { | ||
| id: "3", | ||
| timestamp: "2024-06-21T10:00:00Z", | ||
| action: "Unpause Contract", | ||
| details: "Operations resumed", | ||
| caller: "GBXYZ...ABCD", | ||
| txHash: "fedcba0987654321...", | ||
| }, | ||
| ] | ||
|
|
||
| export default function TransparencyPage() { | ||
| return ( | ||
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> | ||
| <div className="mb-8"> | ||
| <h1 className="text-3xl font-bold text-white mb-2">Transparency Audit Log</h1> | ||
| <p className="text-slate-400"> | ||
| Public record of all admin actions and protocol parameter changes on the SkillSphere smart contracts. | ||
| </p> | ||
| </div> | ||
|
|
||
| <div className="bg-slate-800/50 border border-slate-700 rounded-xl overflow-hidden"> | ||
| <div className="overflow-x-auto"> | ||
| <table className="w-full text-left text-sm"> | ||
| <thead className="bg-slate-800 text-slate-300 border-b border-slate-700"> | ||
| <tr> | ||
| <th className="px-6 py-4 font-semibold">Timestamp</th> | ||
| <th className="px-6 py-4 font-semibold">Action</th> | ||
| <th className="px-6 py-4 font-semibold">Caller</th> | ||
| <th className="px-6 py-4 font-semibold">Transaction Hash</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y divide-slate-700"> | ||
| {ADMIN_EVENTS.map((event) => ( | ||
| <tr key={event.id} className="hover:bg-slate-800/30 transition-colors"> | ||
| <td className="px-6 py-4 text-slate-400 whitespace-nowrap"> | ||
| {new Date(event.timestamp).toLocaleString()} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Format timestamps with an explicit timezone.
🤖 Prompt for AI Agents |
||
| </td> | ||
| <td className="px-6 py-4"> | ||
| <div className="font-medium text-white">{event.action}</div> | ||
| <div className="text-xs text-slate-500 mt-1">{event.details}</div> | ||
| </td> | ||
| <td className="px-6 py-4"> | ||
| <span className="font-mono text-purple-400 bg-purple-400/10 px-2 py-1 rounded"> | ||
| {event.caller} | ||
| </span> | ||
| </td> | ||
| <td className="px-6 py-4"> | ||
| <span className="font-mono text-slate-300"> | ||
| {event.txHash} | ||
| </span> | ||
| </td> | ||
| </tr> | ||
| ))} | ||
|
Comment on lines
+52
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Render the audit entries in a deterministic time order. The current list is already out of sequence ( 🤖 Prompt for AI Agents |
||
| </tbody> | ||
| </table> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,174 +1,39 @@ | ||
| "use client"; | ||
| "use client" | ||
| import { useState } from "react" | ||
|
|
||
| import { useState, useEffect } from "react"; | ||
| import { Activity, AlertCircle } from "lucide-react"; | ||
| import { Card } from "@/components/ui/Card"; | ||
| import { Button } from "@/components/ui/Button"; | ||
| import { useHeartbeatPing } from "@/hooks/useHeartbeat"; | ||
| import { formatTimeAgo } from "@/utils/time"; | ||
| export default function ExpertStats() { | ||
| const [isAvailable, setIsAvailable] = useState(false) | ||
|
|
||
| interface ExpertStatsProps { | ||
| expertId: string; | ||
| initialLastHeartbeat?: number | null; | ||
| } | ||
|
|
||
| export default function ExpertStats({ | ||
| expertId, | ||
| initialLastHeartbeat, | ||
| }: ExpertStatsProps) { | ||
| const { isPinging, pingError, lastHeartbeat, handlePingNow, setLastHeartbeat } = | ||
| useHeartbeatPing(expertId); | ||
|
|
||
| // Initialize with provided heartbeat, update when hook provides new value | ||
| useEffect(() => { | ||
| if (initialLastHeartbeat !== undefined) { | ||
| setLastHeartbeat(initialLastHeartbeat ?? null); | ||
| } | ||
| }, [initialLastHeartbeat, setLastHeartbeat]); | ||
|
|
||
| // STEP E: Re-render every 30 seconds to keep "X mins ago" current | ||
| const [, forceUpdate] = useState({}); | ||
| useEffect(() => { | ||
| const interval = setInterval(() => forceUpdate({}), 30000); | ||
| return () => clearInterval(interval); | ||
| }, []); | ||
|
|
||
| // Determine if heartbeat is considered "fresh" (within 1 hour = 3600 seconds) | ||
| const HEARTBEAT_VALIDITY_WINDOW = 3600; // 1 hour in seconds | ||
| const isHeartbeatFresh = | ||
| lastHeartbeat && | ||
| Math.floor((Date.now() - lastHeartbeat) / 1000) < HEARTBEAT_VALIDITY_WINDOW; | ||
| const handleToggle = () => { | ||
| // Simulated on-chain heartbeat update | ||
| setIsAvailable(!isAvailable) | ||
| } | ||
|
Comment on lines
+5
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Persist the availability state instead of keeping it component-local. Line 5 always reinitializes the toggle to 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="space-y-6"> | ||
| {/* Heartbeat Status Panel */} | ||
| <Card className="p-6 bg-gradient-to-br from-emerald-500/10 to-emerald-600/5 border-emerald-500/20 hover:border-emerald-500/40 transition-colors"> | ||
| <div className="flex items-start justify-between"> | ||
| <div className="flex-1"> | ||
| <div className="flex items-center gap-2 mb-3"> | ||
| <Activity className="w-5 h-5 text-emerald-400" /> | ||
| <h3 className="text-lg font-semibold text-white">Availability Heartbeat</h3> | ||
| </div> | ||
|
|
||
| {/* Heartbeat Status Display */} | ||
| <div className="space-y-3"> | ||
| <div className="bg-white/5 rounded-lg p-4"> | ||
| <div className="flex items-center justify-between mb-2"> | ||
| <span className="text-sm text-slate-400">Last Heartbeat</span> | ||
| {isHeartbeatFresh && ( | ||
| <span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-emerald-500/20 border border-emerald-500/30"> | ||
| <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> | ||
| <span className="text-xs font-medium text-emerald-400">Online</span> | ||
| </span> | ||
| )} | ||
| </div> | ||
| <p className="text-2xl font-bold text-white"> | ||
| {formatTimeAgo(lastHeartbeat)} | ||
| </p> | ||
| {!isHeartbeatFresh && lastHeartbeat && ( | ||
| <p className="text-xs text-yellow-400 mt-2"> | ||
| ⚠️ Not visible in search. Send a heartbeat to stay online. | ||
| </p> | ||
| )} | ||
| {!lastHeartbeat && ( | ||
| <p className="text-xs text-slate-400 mt-2"> | ||
| Never sent a heartbeat. Send one to become visible in search. | ||
| </p> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Heartbeat Information */} | ||
| <div className="bg-white/[0.02] rounded-lg p-3 border border-white/5"> | ||
| <p className="text-xs text-slate-400 leading-relaxed"> | ||
| Send a heartbeat to keep your profile active and visible to seekers. | ||
| Your heartbeat status is valid for <strong>1 hour</strong> after sending. | ||
| Experts without a recent heartbeat cannot accept new sessions. | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Ping Button */} | ||
| <div className="ml-4 flex-shrink-0"> | ||
| <Button | ||
| onClick={handlePingNow} | ||
| disabled={isPinging} | ||
| className="bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-400 border border-emerald-500/30 disabled:opacity-50 disabled:cursor-not-allowed" | ||
| > | ||
| <Activity className="w-4 h-4 mr-2" /> | ||
| {isPinging ? "Pinging..." : "Ping Now"} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Error State */} | ||
| {pingError && ( | ||
| <div className="mt-4 flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20"> | ||
| <AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" /> | ||
| <p className="text-sm text-red-400" role="alert"> | ||
| {pingError} | ||
| </p> | ||
| </div> | ||
| )} | ||
| </Card> | ||
|
|
||
| {/* Online Status Quick Info */} | ||
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | ||
| {/* Status Info Card */} | ||
| <Card | ||
| className={`p-4 border transition-colors ${ | ||
| isHeartbeatFresh | ||
| ? "bg-emerald-500/5 border-emerald-500/20" | ||
| : "bg-slate-500/5 border-slate-500/20" | ||
| <div className="flex items-center gap-3 bg-slate-800/50 px-4 py-2 rounded-full border border-slate-700"> | ||
| <div className="flex items-center gap-2"> | ||
| <div | ||
| className={`w-2.5 h-2.5 rounded-full ${ | ||
| isAvailable ? "bg-green-500" : "bg-gray-500" | ||
| }`} | ||
| > | ||
| <div className="flex items-center gap-3"> | ||
| <div | ||
| className={`w-3 h-3 rounded-full animate-pulse ${ | ||
| isHeartbeatFresh ? "bg-emerald-400" : "bg-slate-400" | ||
| }`} | ||
| /> | ||
| <div> | ||
| <p className="text-xs text-slate-400">Current Status</p> | ||
| <p className={`font-semibold ${ | ||
| isHeartbeatFresh ? "text-emerald-400" : "text-slate-400" | ||
| }`}> | ||
| {isHeartbeatFresh ? "Online & Visible" : "Offline"} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </Card> | ||
|
|
||
| {/* Validity Window Info Card */} | ||
| <Card className="p-4 bg-blue-500/5 border border-blue-500/20"> | ||
| <div className="flex items-center gap-3"> | ||
| <div className="w-3 h-3 rounded-full bg-blue-400" /> | ||
| <div> | ||
| <p className="text-xs text-slate-400">Validity Window</p> | ||
| <p className="font-semibold text-blue-400">1 hour from send</p> | ||
| </div> | ||
| </div> | ||
| </Card> | ||
| /> | ||
| <span className="text-sm font-medium text-slate-200"> | ||
| {isAvailable ? "Available Now" : "Offline"} | ||
| </span> | ||
| </div> | ||
|
|
||
| {/* Recommended Action */} | ||
| {!isHeartbeatFresh && ( | ||
| <div className="bg-gradient-to-r from-purple-500/10 to-pink-500/10 rounded-lg p-4 border border-purple-500/20"> | ||
| <h4 className="font-semibold text-white mb-2">📌 Stay Visible</h4> | ||
| <p className="text-sm text-slate-300 mb-3"> | ||
| Send a heartbeat regularly to maintain your visibility in the search index. | ||
| Consider setting up automatic heartbeats to avoid going offline. | ||
| </p> | ||
| <Button | ||
| onClick={handlePingNow} | ||
| disabled={isPinging} | ||
| className="bg-purple-600 hover:bg-purple-700 text-white" | ||
| size="sm" | ||
| > | ||
| {isPinging ? "Pinging..." : "Send Heartbeat Now"} | ||
| </Button> | ||
| </div> | ||
| )} | ||
| <button | ||
| onClick={handleToggle} | ||
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ | ||
| isAvailable ? "bg-green-500/30" : "bg-slate-700" | ||
| }`} | ||
| aria-label="Toggle availability" | ||
| > | ||
| <span | ||
| className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${ | ||
| isAvailable ? "translate-x-6" : "translate-x-1" | ||
| }`} | ||
| /> | ||
|
Comment on lines
+24
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Expose the current toggle state to assistive tech. This is implemented as a switch, but the button only announces “Toggle availability” and not whether it is currently on or off. Add switch/toggle state semantics such as 🤖 Prompt for AI Agents |
||
| </button> | ||
| </div> | ||
| ); | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,21 +28,46 @@ export default function FundSessionModal({ | |
| const [currentStep, setCurrentStep] = useState<Step>('duration'); | ||
| const [duration, setDuration] = useState<number>(60); // minutes | ||
| const [isProcessing, setIsProcessing] = useState(false); | ||
| const [rentFee, setRentFee] = useState<number | null>(null); | ||
| const [isEstimatingRent, setIsEstimatingRent] = useState(false); | ||
|
|
||
| const hourlyRate = parseInt(expertHourlyRate?.replace(/\D/g, '') || '50'); | ||
| const amount = (hourlyRate * duration) / 60; | ||
| const sessionId = `SESSION_${Date.now()}`; | ||
|
|
||
| // Simulate RPC call for state rent | ||
| const fetchRentEstimate = async () => { | ||
| setIsEstimatingRent(true); | ||
| try { | ||
| // Dummy RPC dry-run simulation | ||
| await new Promise((resolve) => setTimeout(resolve, 800)); | ||
| setRentFee(0.015); // Mock rent fee in XLM | ||
| } catch (e) { | ||
| console.error("Failed to estimate rent fee", e); | ||
| setRentFee(0.01); // Fallback mock fee | ||
|
Comment on lines
+45
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Treat rent-estimation failures as blocking, not as If the dry-run fails here, 🤖 Prompt for AI Agents |
||
| } finally { | ||
| setIsEstimatingRent(false); | ||
| } | ||
| }; | ||
|
|
||
| React.useEffect(() => { | ||
| if (isOpen) { | ||
| fetchRentEstimate(); | ||
| } | ||
| }, [isOpen, duration]); | ||
|
Comment on lines
+31
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Track the latest rent-estimate request before re-enabling checkout. Lines 38-57 can overlap when the user changes duration quickly. An older 🤖 Prompt for AI Agents |
||
|
|
||
| const walletBalance = balance !== null ? parseFloat(balance) : null; | ||
| const totalRequired = amount + (rentFee || 0) + MIN_XLM_FOR_FEES; | ||
|
|
||
| const isBalanceUnavailable = | ||
| isLoading || | ||
| error !== null || | ||
| !address || | ||
| walletBalance === null || | ||
| Number.isNaN(walletBalance); | ||
| const hasInsufficientBalance = | ||
| !isBalanceUnavailable && walletBalance < amount + MIN_XLM_FOR_FEES; | ||
| const cannotProceed = isBalanceUnavailable || hasInsufficientBalance; | ||
| !isBalanceUnavailable && walletBalance < totalRequired; | ||
| const cannotProceed = isBalanceUnavailable || hasInsufficientBalance || isEstimatingRent; | ||
|
|
||
| const handleDurationChange = (value: number) => { | ||
| setDuration(value); | ||
|
|
@@ -145,9 +170,13 @@ export default function FundSessionModal({ | |
| <span className="text-muted-foreground">Duration</span> | ||
| <span>{duration} minutes</span> | ||
| </div> | ||
| <div className="flex justify-between text-sm"> | ||
| <span className="text-gray-400">Estimated State Rent Fee</span> | ||
| <span>{isEstimatingRent ? '...' : rentFee ? `${rentFee.toFixed(3)} XLM` : '0 XLM'}</span> | ||
| </div> | ||
| <div className="border-t border-purple-500/20 pt-3 flex justify-between font-bold"> | ||
| <span>Total Amount</span> | ||
| <span className="text-purple-400">${amount.toFixed(2)} XLM</span> | ||
| <span className="text-purple-400">${(amount + (rentFee || 0)).toFixed(3)} XLM</span> | ||
|
Comment on lines
+173
to
+179
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a single currency label for the XLM totals. These rows currently render values like Also applies to: 209-215 🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
|
|
||
|
|
@@ -159,7 +188,7 @@ export default function FundSessionModal({ | |
| disabled={cannotProceed} | ||
| className="w-full px-4 py-3 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 disabled:from-purple-600/50 disabled:to-pink-600/50 rounded-lg font-semibold transition-all flex items-center justify-center gap-2 disabled:cursor-not-allowed" | ||
| > | ||
| Continue | ||
| {isEstimatingRent ? 'Estimating Rent...' : 'Continue'} | ||
| <ChevronRight size={20} /> | ||
| </button> | ||
| </div> | ||
|
|
@@ -177,9 +206,13 @@ export default function FundSessionModal({ | |
| <span className="text-muted-foreground">Duration</span> | ||
| <span className="font-semibold">{duration} minutes</span> | ||
| </div> | ||
| <div className="flex justify-between items-center"> | ||
| <span className="text-gray-400">Estimated State Rent Fee</span> | ||
| <span className="font-semibold">{rentFee ? `${rentFee.toFixed(3)} XLM` : '0 XLM'}</span> | ||
| </div> | ||
| <div className="flex justify-between items-center pb-4 border-b border-purple-500/20"> | ||
| <span className="text-muted-foreground">Total Amount</span> | ||
| <span className="font-semibold text-lg text-purple-400">${amount.toFixed(2)} XLM</span> | ||
| <span className="text-gray-400">Total Amount</span> | ||
| <span className="font-semibold text-lg text-purple-400">${(amount + (rentFee || 0)).toFixed(3)} XLM</span> | ||
| </div> | ||
| </div> | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Replace the hardcoded audit log with a real event source.
This page claims to be the public record of contract admin actions, but
ADMIN_EVENTSis just static sample data. Shipping it as-is will show fictitious/stale caller addresses and tx hashes, which defeats the transparency feature.Also applies to: 35-36
🤖 Prompt for AI Agents